JSとデータベースでの時刻の取り扱い

JSのDate型について気になったので改めて確認した。

JSのDateはTimezoneを持っている。

const date = new Date()

このdateをコンソールで確認すると以下になる。 Wed May 01 2024 21:53:33 GMT+0900 (日本標準時)

Dateインスタンスシリアライズする場合は、ISO 8601に従う。JSON.stringifyもそうなっている。

JSON.stringify({date1: date, date2: date.toISOString()})

は以下になる。

{
  "date1":"2024-05-01T12:55:37.125Z",
  "date2":"2024-05-01T12:55:37.125Z"
}

JSのDateインスタンスタイムゾーン情報を保持するので、getTime()やtoISOStringで使う限りは、 クライアント、サーバサイドのタイムゾーンが異なる環境において、どこでnew Date()しても問題はない。

ISO 8601形式の時刻の文字列をDateインスタンスに戻すには、以下でよい。

new Date("2024-05-01T12:55:37.125Z")

インスタンスにする環境のタイムゾーンJSTであれば以下に戻る。 Wed May 01 2024 21:53:33 GMT+0900 (日本標準時)

集計時

日毎のアクセス数を集計したいとする。 アクセスログから、アクセス数を日毎に集計する場合には、通常アクセスログのタイムスタンプを日時に丸め(切り落とし)て、集計する。 この時、データベースサーバにどのタイムゾーンでデータが保存されているかはよく確認しておかないと、集計される範囲がずれてしまうことがある。

たとえば、ORマッパーのprismaを使うとJSのDateインスタンスUTCに直して保存される。 そのため、SQLで集計する場合は、タイムゾーンを適用する必要がある。

例えば、SQLサーバの場合の集計用のSQLを書いてみると以下になる。

select
  format(dateadd(hh, 9, time), 'yyyy-MM-dd') as date
  , email
  , count(*) as value
from
  access
group by
  format(dateadd(hh, 9, time), 'yyyy-MM-dd'), email
order by
  date

このSQLでは、timeカラムの値をdateadd(hh, 9, time)UTCから日本時間に変換し、その後format(dateadd(hh, 9, time), 'yyyy-MM-dd')で日時文字列に変換している。 これらの関数は方言があるのでデータベース毎に変更する必要はある。

書評:10年戦えるデータ分析入門: SQLを武器にデータ活用時代を生き抜く

気になって読んでみたらなかなかよかった。だいたい1日で大体読み切れたのもよい。

www.sbcr.jp

RDB/SQLの情報はだいたいOLTPを向いたものが多い中で、データ分析や活用を向いていて、内容も初歩的ではあるが実践的だと感じた。 SQL自体を解説している書籍では、網羅的だが、読むのが辛いことが多いのだが、本書は、SQL自体の使い方もデータ分析目的での説明なので、リファレンス的な本と比べて読みやすい。 さらに、SQLを使ったバッチの書き方や、SQL単体のテスト方法などもかかれていてなかなか興味深い。他の書籍ではあまりみたことがない、

とはいえ、書かれた時期がずいぶん前なので、Hadoopや商用製品周りの話は古くなっている。ゆえになつかしさがあって自分には逆によかった。

なお、著者の名前をみたことがある気がしたら、『ふつうのLinuxプログラミング』も読んでいたようだ。なかなかに良かった記憶。

NextAuth.jsで独自サインインページを作成する場合の注意すべきこと

NextAuth.jsには標準でもサインインページとサインアウトページは用意されているのだが、実務ではそのまま使いづらく、独自のサインインページを用意したいことが多いのではないかと思う。

本稿では、独自のサインインページを作成する場合の設定の概要と注意点を共有する。

AuthOptionsの設定

AuthOptionsのpagesにサインインページのパスを指定してやればよい。

import { AuthOptions } from "next-auth";
...

function newAuthOptions() {
  return {
    providers: [
      AutodeskProvider({
        ...
      })
    ],
    ...
    pages: {
      signIn: '/signin',
      signOut: '/signin',
      error: '/signin',
    },
  } satisfies AuthOptions;
}

逆に言えば、このサインインページのパス/signInに独自のサインインページを作っておく必要がある。

サインインページの作成

独自のサインインページを作るにあたって、サインインサインアウトの処理はnext-auth/reactsignIn, signOut関数をimportして、ボタンのonClickなどに割り当ててやればよい。

import { signIn, signOut } from "next-auth/react";
...

function SigninComponents(props: Props) {

  const params = useSearchParams();
  const error = params.get('error');
  const callbackUrl = params.get('callbackUrl') || "/";

  async function signInHandle() {
    await signIn('autodesk', { callbackUrl })
  }

signIn関数の呼び出しにあたっては、上記の例に示す通り、いくつかの引数を指定したほうが望ましい。

1番目の引数には、利用するOIDCプロバイダを指定する。OIDCプロバイダがひとつだけの場合は引数で指定することで、無駄なプロバイダ選択画面をスキップすることができる。

2番目の引数には、サインイン後にリダイレクトするURLをcallbackUrlフィールドに持たせて指定する必要がある。例えば、/sampleが認証が必要なページだとして、認証なしでアクセスすると、サインインページに飛ばされて認証を求められる。その際、認証後は当初アクセスしたかった/sampleにリダイレクトされることが親切である。 また、このcallbackUrlを指定しない場合は、サインインURLにそのまま戻ってきてしまう。今回は/signinというURLに専用のサインインページを設けるOptionを例に示したが、この例の場合は認証後は認証後にアクセスできるページにリダイレクトされてほしいのではないかと思う。その場合、指定されていない場合にリダイレクトするページを指定してやる必要がある。

このあたりのふるまいは意外とユーザの使い勝手に大きく影響するため、気を付けて設定しておきたい。設定については以上である。

NextAuth.jsのセッションからAccessTokenを取得できるようにする。

一般的な話であるが、OAuth認証の基本的な仕組みを前提として確認する。OAuth認証を利用するWebアプリは、OIDCプロバイダから提供されたアクセストークンの正しさを、ユーザプロファイル情報の取得可否によって確認する。つまり、認証の過程においてWebアプリは、OIDCプロバイダのアクセストークンを取得している。この取得したアクセストークンを保持しておいて、OIDCプロバイダの提供するAPI呼び出しに使おう考えるのはごく自然なことである。

そういうわけで、本記事ではNextAuth.jsでの認証後、アクセストークンを取得する方法について扱う。 NextAuth.jsの仕組み上、アクセストークンはセッションに保存し、利用する際もセッションデータから取得することになる。

本記事で扱ったNextAuth.jsのバージョンは4.24.5である。過去記事でも検討したAutodeskのAPSをOIDCプロバイダとして利用して検証した。

ma38su.hatenablog.com

型情報の拡張

NextAuth.jsのSession型には、アクセストークンは含まれていない。そのため、型定義を拡張する必要がある。 とりあえず、以下のファイルを作成することで必要な型の拡張ができる。 (ここの細かい仕組みはよくわかっていない。)

/app/types/next-auth.d.ts

import { DefaultSession } from "next-auth";
import { JWT } from "next-auth/jwt";

declare module "next-auth" {
  interface Session {
    accessToken?: string;
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    accessToken?: string;
  }
}

認証処理の設定

callbacksに、sessionとjwtの処理を追加する。

/app/api/auth/[...nextauth]/option.ts

import { AuthOptions } from "next-auth";

...

function newAuthOptions() {
  return {
    providers: [
      AutodeskProvider({ ...  })
    ],
    callbacks: {
      async session(params) {
        const { session, token } = params;
        // JWTトークンのaccessTokenをセッションへコピーする。
        session.accessToken = token.accessToken;
        return session;
      },
      async jwt(params) {
        const { token, account } = params;
        if (account) {
          // サインイン時にアクセストークンをJWTに保存する。
          token.accessToken = account.access_token
        }
        return token
      },
    },
    ...
  } satisfies AuthOptions
}

export const options = newAuthOptions();

これで認証処理の設定はOK。アクセストークンはサインイン時にJWTに保存するので、テストする場合はサインインしなおす必要がある。

セッション情報の取得

サーバサイドでsessionを取得する場合は以下。

import { options } from "../auth/[...nextauth]/option";

export async function GET(req: Request) {
  const session = await getServerSession(options);
  ...
}

getServerSessionの引数には認証処理を設定したoptionsを渡さないとアクセストークンがセッションに含まれてこない。

クライアントサイドの場合はSessionProviderでWrapしたうえでuseSessionで取得する。 App Routerを利用している場合はuse clientが必須。

"use client"
import { SessionProvider } from "next-auth/react"
import { useSession } from "next-auth/react"

function App() {
  return <SessionProvider>
    <SessionView />
  </SessionProvider>
}

function SessionView() {
  const { data: session } = useSession()
  return <pre>{JSON.stringify(session)}</pre>
}

参考文献

公式に大体書いてある。

next-auth.js.org

Next.JS App RouterにおいてSVGをAPIから出力する方法

SVGを最近よく活用している。CanvasよりReactとの相性はよいし、ベクターなのも好み。 (Three.jsを使えると面白いけど、、その機会はなかなかない。)

だいたいの場合は、HTMLに埋め込んで使えばいいので、特別なことをする必要はない。 ただ、たまに画像として保存したり、書き出したりしたいことがある。PPT用素材として使うとか、コンテンツとして独立させて、Markdownドキュメントから読み込ませるなど。

そういったケースにも対応するため、Next.JSのApp Router環境において、SVGをサーバサイドで生成して画像として提供する方法を探していたのだが、やっと解決策が見つかった。

以下のような感じ。

/app/api/svg/route.tsx

import React from "react";

function MySvg() {
  return (
    <svg version="1.1"
        baseProfile="full"
        width="300" height="200"
        xmlns="http://www.w3.org/2000/svg">
      <rect width="100%" height="100%" fill="red" />
      <circle cx="150" cy="100" r="80" fill="green" />
      <text x="150" y="125" font-size="60" text-anchor="middle" fill="white">SVG</text>
    </svg>
  )
}

export async function GET() {
  const ReactDOMServer = (await import('react-dom/server')).default
  const body = ReactDOMServer.renderToStaticMarkup(<MySvg />);

  return new Response(
    body, {
    status: 200,
    headers: {
      "Content-Type": "image/svg+xml",
    },
  });
}

これで/api/svgへアクセスすると、MySVGの内容がブラウザに表示される。

ChartGPT 4に相談すると

import { renderToStaticMarkup } from 'react-dom/server'

を教えてくれた。助かる。

ただ、そのままimportすると、

Error: 
  × You're importing a component that imports react-dom/server. To fix it, render or return the content directly as a Server Component instead for perf and security.

セキュリティの問題なのか、エラーがでてしまった。 githubながめてたらdynamic importで対応できるようだった。

  const ReactDOMServer = (await import('react-dom/server')).default

staticなsvg生成の範囲では問題ないと思うのだが、使用する場合はリスクもしっかり検討する必要はありそうではある。

関連する試みとしては、過去にPNGAPI経由で提供するケースも検討した。 ma38su.hatenablog.com

DeepL APIの試用

APIを試してみたのでメモ。

import 'dotenv/config'

const API_KEY = process.env.DEEPL_API_KEY;
const API_DOMAIN = process.env.DEEPL_API_DOMAIN;
if (API_KEY == null || API_DOMAIN == null) {
  throw new Error();
}

const API_ENDPOINT = `https://${API_DOMAIN}/v2/translate`;

type TranslationLang = 'JA' | 'EN';

async function main() {
  const text = '翻訳したい言葉をここに書く。';
  await translate(text, 'JA', 'EN');
}

type TranslationResponse = {
  translations: { detected_source_language: TranslationLang, text: string }[]
}

async function translate(
  text: string, sourceLang: TranslationLang, targetLang: TranslationLang
): Promise<void> {

  const body = new FormData();
  body.append('text', text);
  body.append('target_lang', targetLang);
  body.append('source_lang', sourceLang);

  const res = await fetch(API_ENDPOINT, {
    method: 'POST',
    headers: {
      Authorization: `DeepL-Auth-Key ${API_KEY}`
    },
    body,
  });
  if (!res.ok) {
    throw new Error();
  }

  const { translations } = await res.json() as TranslationResponse
  const textList = translations.map(({text}) => text);
  console.log(textList)
}

ただ個人アカウントだと、DeepL API Proを契約すると、DeepL Proが契約できなくなるっぽくてちょっと困っている。

画像を返すAPIの作成

Azure Static Web Appではstaticなファイルにはmiddlewareが機能しないっぽいので、 画像にも認証をかけたい場合はAPIで画像を返すようにする必要がある。

Static Web Appってそういうものだよなーと思っているので仕方ない気がするし、 そもそも認証が必要なアプリはStatic Web App向きではないような気がしているものの、 パワープレイでなんとかしたいこともある。

ということで、非公開パスにある画像を読み込んで返すAPIを組んでみる。 以下のコードはNext.js(App Router)のもの。

import * as fs from "node:fs/promises";

export async function GET() {
  const closedPath = ...
  const content = await fs.readFile(path, { encoding: "binary" } );
  return new Response(Buffer.from(content, 'binary'), {
    status: 200,
    headers: {
      "Content-Type": "image/png",
    },
  });
}

これで認証チェックを追加すればOK。 いろんな画像フォーマットに対応するには拡張子とかでContent-Typeを切り替えればよさそう。

もう少し気になっているのはSVGを返したい場合。ちょっと勝手が違う気はするのでまた別途調べる。