かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

Vite React (TypeScript) で SVG を ReactComponent として扱いたい

create-react-app で作成した React のプロジェクトでは SVG ファイルを ReactComponent としてインポートすると inline-svg (<svg></svg>) として出力できるので path に CSS を当てたい場合などに便利です。

今回 Vite で作成した React (TypeScript) のプロジェクトで同じことをしようとしたのですがデフォルトの状態では SVGReactComponent として import できませんでした。

vite-plugin-svgr を使う

内部的に svgr という SVG を ReactComponent に変換できるツールを使っている vite-plugin-svgr プラグインがあったのでこちらを使うことにしました

Then SVG files can be imported as React components, just like create-react-app does
cf. vite-plugin-svgr - npm

vite.config.tsプラグインを追加すれば import { ReactComponent as Logo } from './logo.svg' の形で SVG React 内で扱えるようになります

$ npm i -D vite-plugin-svgr

vite.config.ts

import svgr from 'vite-plugin-svgr'

export default {
  // ...
  plugins: [svgr()],
}

SVG のインポート文で TypeScript のエラーが出る

SVG を ReactComponent としてインポートしている箇所で次のような TypeScript のエラーが表示されていました。(ビルドはされていたので警告かも)

// App.tsx
import { ReactComponent as Logo } from './logo.svg';

=> Module '"*.svg"' has no exported member 'ReactComponent'. Did you mean to use 'import ReactComponent from "*.svg"' instead?

SVG ファイルは ReactComponent として export されてないトノコト。
それはそう。

.svg ファイルは ReactComponent として export さている体の型を宣言してしまえば OK

適当な .d ファイルを作成して型定義する

svg.d.ts

declare module '*.svg' {
  import React = require('react');
  export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
}

cf. reactjs - Module '"*.svg"' has no exported member 'ReactComponent' - Stack Overflow

これで import { ReactComponent as ComponentName } from './path/to/svgFile.svg' の型エラーが解消され JSX 内でコンポーネントとして扱えるようになりました!!

// App.tsx
import { ReactComponent as Logo } from './logo.svg';

return App: FC = () => {
  return <div><Logo /></div>;
};

₍ ᐢ. ̫ .ᐢ ₎ できた!


[参考]

Express (TypeScript) で立てた GraphQLサーバーでセッションを使うメモ

Express (TypeScript) で作った local 開発用に立てた実験用サーバーに express-session モジュールを使ってセッションを使えるようにしたのメモ

環境
  • express@4.18.1
  • graphql@16.5.0
  • express-graphql@0.12.0
  • express-session@1.17.3
  • typescript@4.7.2

express-sesssion

Warning The default server-side session storage, MemoryStore, is purposely not designed for a production environment. It will leak memory under most conditions, does not scale past a single process, and is meant for debugging and developing.
cf. express-session - npm

ただし express-sesssion はメモリ上に保存されるあくまで開発用とトノコト。
ts-node-dev でホットリロードにしている場合サーバーが再起動するとセッションに保存していた内容が消えます

パッケージのインストール

$ npm i express-session
# TypeScript 用の型もインストールする
$ npm i -D @types/express-session

express-session でセッションを使う

server.ts

import express from 'express';
import session, { SessionOptions } from 'express-session';

const app = express();
// session
const sess: SessionOptions = {
  // dummy
  secret: 'session_secret',
  // 1min = 60 * 1000
  cookie: { maxAge: 60000 },
  resave: false,
  saveUninitialized: false,
};
app.use(session(sess));

resavesaveUninitialized のオプションは未だ理解しきれてませんがデフォルトでの使用は非推奨とのことだったので、まぁサンプルだし動作してるから…ということで両方 false にしました

Session に値を保存する

ドキュメントには req.session.views というプロパティを使ってセッションに値を保存する例が載っていましたが、TypeScript だとそのままでは views が未定義というエラーになってしまいました

server.ts

import express, { Request } from 'express';
import { buildSchema } from 'graphql';
import { graphqlHTTP, GraphQLParams } from 'express-graphql';
import session, { SessionOptions } from 'express-session';

const schema = buildSchema(`
  type Mutation {
    increment: Counter
  }
  type Counter {
    count: Int!
  }
`);
type Counter = {
  count: number;
};

const root = {
  increment: (_: GraphQLParams['variables'], req: Request): Counter => {
    const count = req.session.views || 0;
    // => Property 'views' does not exist on type 'Session & Partial<SessionData>'.

    req.session.views = count + 1;

    return {
      count: req.session.views,
    };
  },
}

const app = express();
// 略
app.use(session(sess));
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
  })
);

=> Property 'views' does not exist on type 'Session & Partial<SessionData>'.

SessionData の型定義をすればOK

GitHub の issue に解決方法が載っていました

server.ts に下記の定義を追加すればOK

declare module "express-session" {
  interface SessionData {
    views: any;
  }
}

これで req.session.views の TypeError が解消されました!

TypeScript なのだから型が決まった独自プロパティをもたせてしまったほうが良い

declare module "express-session" { interface SessionData {} } は型定義なので views 以外の独自プロパティも設定することが可能なので views: any より count: number のように型エラーやエディタの保管が効く型を設定するほうが TypeScript の恩恵を受けられます

server.ts

declare module "express-session" {
  interface SessionData {
-   views: any;
+   count: number;
  }
}

const root = {
  increment: (_: GraphQLParams['variables'], req: Request): Counter => {
-   const count = req.session.views || 0;
+   const count = req.session.count || 0;

-   req.session.views = count + 1;
+   req.session.count = count + 1;

    return {
-     count: req.session.views,
+     count: req.session.count,
    };
  },
}

mutation で session にデータを保存する

mutation で送ったパラメーターを session に保存する例

server.ts

const schema = buildSchema(`
  type Query {
    user: User
  }
  type Mutation {
    login(username: String!): User
    logout: Boolean!
  }
  type User {
    username: String!
  }
`);
type UserType = {
  username: string;
};

const root = {
  user: (_: GraphQLParams['variables'], req: Request): UserType => {
    const username = req.session?.userName;
    if (!username) {
      throw new Error('No logged in user!');
    }

    return {
      username,
    };
  },
  // Mutation
  login: ({ username }: { username: string }, req: Request): UserType => {
    if (username === '') {
      throw new Error('User Name is required!');
    }

    // set username
    req.session.userName = username;

    return {
      username,
    };
  },
  logout: (_: GraphQLParams['variables'], req: Request): Boolean => {
    req.session.userName = undefined;

    return true;
  },
};

// 略

👇 下記のクエリが動作していれば OK

username をセッションに保存

mutation Login($name: String!) {
  login(username:$name) {
    username
  }
}

// Query Variables
{
  "name": "Kiriya Aoi"
}

セッションの username を破棄

mutation Logout {
  logout
}

セッションに保存されている username を取得。存在しなければエラーを返す

query User {
  user {
    username
  }
}

セッションを利用した場合の実験もできるようになりました!
Express は Nest.js に比べて自由すぎるから大変だという話も聞きますが、自由だからこそサクッと実験をするにはとても良きフレームワークだな〜と改めて感じたのでした。らぶ… (ファイル1つで済ませられるし)

おわり。


[参考]

Express GraphQL サーバーから返すエラーに独自フィールドを追加したい

Express (TypeScript) で作成した GraphQL サーバーがエラーを返す際に message 以外の独自フィールドを追加してみたメモ

  • express@4.18.1
  • graphql@15.8.0
  • express-graphql@0.12.0
  • typescript@4.7.2

Express GraphQL サーバー

Mutation で username を引数にした login にクエリを投げ username が存在すればログインした体で username を返し、存在しなければエラーを返すだけの簡単な GraphQL サーバー

// server.ts
import express, { Request } from 'express';
import { graphqlHTTP } from 'express-graphql';
import { buildSchema, GraphQLError } from 'graphql';
// Schema
const schema = buildSchema(`
  type Mutation {
    login(username: String!): User
  }
  type User {
    username: String!
  }
`);
// Resolver
const root = {
  login: ({ username }: { username: string }) => {
    if (username === '') {
      throw new Error('User Name is required!');
    }

    return { username };
  },
};

const app = express();
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
  }),
);
app.listen(4000);
);

サーバーを起動して localhost:4000/graphql にアクセスして表示される GraphiQL の画面で下記のようなクエリを発行してレスポンスが帰ってくればOK

mutation Login($username: String!) {
  login(username: $username) {
    username
  }
}
# Query Variables
{
  "username": "Hoshimiya Ichigo"
}

👇 Response

{
  "data": {
    "login": {
      "username": "Hoshimiya Ichigo"
    }
  }
}

username が空文字の場合はエラー

# Query Variables
{
  "username": ""
}

👇 Response

{
  "errors": [
    {
      "message": "User Name is required!",
      "locations": [ {} ],
      "path": ["login"]
    }
  ],
  "data": {
    "login": null
  }
}

Resolver から throw するエラーに独自のフィールドを持たせる

username が空文字の時に返すエラーに errorType: string という独自フィールドを付けて返したい!

GraphQLError クラスを拡張した独自のエラー型を作成する

express-graphql ではエラーの際に graphql の GraphQLError をエラーとして返しているようでした
TypeScript では好きにプロパティを追加できなかったので、独自のフィールドを持った GraphQLError を拡張したクラスを作成します

// CustomError.ts
import { GraphQLError } from 'graphql';

type CustomErrorProps = {
  message: string;
  errorType?: string;
};

export class CustomError extends GraphQLError {
  errorType?: string;

  constructor({ message, errorType }: CustomErrorProps) {
    super(message);
    this.errorType = errorType;
  }
}

サーバーの Resolver でエラーを投げている箇所を CustomError を使うように変更します

// server.ts
+ import { CustomError } from './CustomError';

const root = {
  login: ({ username }: { username: string }) => {
    if (username === '') {
-     throw new Error('User Name is required!');
+     throw new CustomError({
+       message: 'User Name is required!',
+       errorType: 'AuthenticationError',
+     });
    }

    return { username };
  },
};

クライアントに返されるエラーに独自フィールドを追加する

express-graphql にはエラーのフォーマットを行う formatError customFormatErrorFn という API が用意されているので、これを使って独自フィールドをエラーに追加します
(※ formatError は express-graphql v 1.0.0 で deprecated になっていました cf. GitHub - graphql/express-graphql: Create a GraphQL HTTP server with Express.)

formatError

function formatError(error: GraphQLError): GraphQLFormattedError

type GraphQLFormattedError = {
  message: string,
  locations: ?Array<GraphQLErrorLocation>
};

type GraphQLErrorLocation = {
  line: number,
  column: number
};

cf. graphql/error | GraphQL

customFormatErrorFn に渡されるエラーオブジェクトは GraphQLError クラスにラップされる

CustomError クラスを throw しているので customFormatErrorFn に渡されるエラーオブジェクトの型を GraphQLError | CustomError な Union 型にすればうまく動作すると思ったのですが、customFormatErrorFn に渡されるエラーオブジェクトは GraphQLError に再度ラップされてしまうようで直接独自フィールドを取得することはできませんでした (ᐡ o̴̶̷̤ ﻌ o̴̶̷̤ ᐡ)

// server.ts
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
    customFormatErrorFn(error: GraphQLError | CustomError) {
      console.log(error instanceof GraphQLError); // => true
      console.log(error instanceof CustomError); // => false

      return {
        ...error,
        errorType: error?.errorType || undefined,
        // => Unable to compile TypeScript:
        // => Property 'errorType' does not exist on type 'GraphQLError | CustomError'.
        // => Property 'errorType' does not exist on type 'GraphQLError'.
      };
    }
  }),
);

error: GraphQLError | CustomError という Union 型を指定しましたが、error?.errorType と独自フィールドにアクセスしている箇所で次のような TypeScript のコンパイルエラーが発生してしまいます
=> Property 'errorType' does not exist on type 'GraphQLError | CustomError'. Property 'errorType' does not exist on type 'GraphQLError'.

customFormatErrorFn に渡されるエラーオブジェクトは常に error instanceof GraphQLError => true かつ error instanceof CustomError => false です

error.originalError から独自フィールドの値を取り出すようにすればOK

GraphQLErrororiginalError というプロパティに元のエラーオブジェクトを持っています。CustomError クラスを throw した場合は error.originalErrorCustomErrorインスタンスになっているので、ここから独自フィールドを取得するようにすればクライアントに独自のエラー情報を返せます

独自フィールドを取得して返す関数を作成

// CustomError.ts
import { GraphQLError } from 'graphql';

export const getErrorType = (error: GraphQLError): string | null => {
  const errorType = (error.originalError as Record<string, any>)?.errorType;
  if (errorType && typeof errorType === 'string') {
    return errorType;
  }

  return null;
};

独自フィールドを取得してクライアントに返すように修正

// server.ts
- import { CustomError } from './src/CustomError';
+ import { CustomError, getErrorType } from './src/CustomError';

// server.ts
app.use(
  '/graphql',
  graphqlHTTP({
    schema,
    rootValue: root,
    graphiql: true,
-   customFormatErrorFn(error: GraphQLError | CustomError) {
+   customFormatErrorFn(error: GraphQLError) {
+     const errorType = getErrorType(error);

      return {
        ...error,
-       errorType: error?.errorType || undefined,
+       errorType,
      };
    }
  }),
);

これでサーバーを起動して username を空文字にしたクエリを発行すると独自フィールド errorType を含むエラーが返されるようになりました! Error response has Custom Error Fields!

所感

Express で作った GraphQL サーバーのエラーをカスタマイズできるようになりました!
レベルアップを感じます ₍ ᐢ. ̫ .ᐢ ₎ ₍ ᐢ. ̫ .ᐢ ₎ ₍ ᐢ. ̫ .ᐢ ₎ ヤッタゼ!!

今回も TypeScript の型との戦いでした。
でも JavaScript で書いていたらコンパイルエラーならず動作はしているのに error?.errorType が常に undefined になってしまい何でや!! ともっとハマってしまっていたと思います。ありがとう TypeScript !!


[参考]