diff --git a/is.ts b/is.ts index 574afcd..d32b173 100644 --- a/is.ts +++ b/is.ts @@ -1,3 +1,4 @@ +import type { ErrorLike } from "./deps/scrapbox.ts"; // These code are based on https://deno.land/x/unknownutil@v1.1.0/is.ts export const isNone = (value: unknown): value is undefined | null => @@ -8,3 +9,28 @@ export const isNumber = (value: unknown): value is number => typeof value === "number"; export const isArray = (value: unknown): value is T[] => Array.isArray(value); +export const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const isErrorLike = (e: unknown): e is ErrorLike => { + if (!isObject(e)) return false; + return (e.name === undefined || typeof e.name === "string") && + typeof e.message === "string"; +}; + +/** 与えられたobjectもしくはJSONテキストをErrorLikeに変換できるかどうか試す + * + * @param e 試したいobjectもしくはテキスト + * @return 変換できなかったら`false`を返す。変換できたらそのobjectを返す + */ +export const tryToErrorLike = (e: unknown): false | ErrorLike => { + try { + const json = typeof e === "string" ? JSON.parse(e) : e; + if (!isErrorLike(json)) return false; + return json; + } catch (e2: unknown) { + if (e2 instanceof SyntaxError) return false; + // JSONのparse error以外はそのまま投げる + throw e2; + } +}; diff --git a/rest/auth.ts b/rest/auth.ts new file mode 100644 index 0000000..3fa37d1 --- /dev/null +++ b/rest/auth.ts @@ -0,0 +1,28 @@ +import { getProfile } from "./profile.ts"; +import { BaseOptions } from "./util.ts"; + +// scrapbox.io内なら`window._csrf`にCSRF tokenが入っている +declare global { + interface Window { + _csrf?: string; + } +} + +/** HTTP headerのCookieに入れる文字列を作る + * + * @param sid connect.sidに入っている文字列 + */ +export const cookie = (sid: string): string => `connect.sid=${sid}`; + +/** CSRF tokenを取得する + * + * @param init 認証情報など + */ +export const getCSRFToken = async ( + init?: BaseOptions, +): Promise => { + if (window._csrf) return window._csrf; + + const user = await getProfile(init); + return user.csrfToken; +}; diff --git a/rest/error.ts b/rest/error.ts new file mode 100644 index 0000000..3b90966 --- /dev/null +++ b/rest/error.ts @@ -0,0 +1,26 @@ +export class UnexpectedResponseError extends Error { + name = "UnexpectedResponseError"; + status: number; + statusText: string; + body: string; + path: URL; + + constructor( + init: { status: number; statusText: string; body: string; path: URL }, + ) { + super( + `${init.status} ${init.statusText} when fetching ${init.path.toString()}`, + ); + + this.status = init.status; + this.statusText = init.statusText; + this.body = init.body; + this.path = init.path; + + // @ts-ignore only available on V8 + if (Error.captureStackTrace) { + // @ts-ignore only available on V8 + Error.captureStackTrace(this, UnexpectedResponseError); + } + } +} diff --git a/rest/mod.ts b/rest/mod.ts index 708592a..9a45976 100644 --- a/rest/mod.ts +++ b/rest/mod.ts @@ -3,3 +3,6 @@ export * from "./project.ts"; export * from "./profile.ts"; export * from "./replaceLinks.ts"; export * from "./page-data.ts"; +export * from "./auth.ts"; +export * from "./util.ts"; +export * from "./error.ts"; diff --git a/rest/page-data.ts b/rest/page-data.ts index e0bee80..57a9f27 100644 --- a/rest/page-data.ts +++ b/rest/page-data.ts @@ -6,39 +6,27 @@ import type { NotLoggedInError, NotPrivilegeError, } from "../deps/scrapbox.ts"; -import { - cookie, - getCSRFToken, - makeCustomError, - tryToErrorLike, -} from "./utils.ts"; -import type { Result } from "./utils.ts"; - -/** `importPages`の認証情報 */ -export interface ImportInit { - /** connect.sid */ sid: string; - /** CSRF token - * - * If it isn't set, automatically get CSRF token from scrapbox.io server. - */ - csrf?: string; -} +import { cookie, getCSRFToken } from "./auth.ts"; +import { UnexpectedResponseError } from "./error.ts"; +import { tryToErrorLike } from "../is.ts"; +import { BaseOptions, ExtendedOptions, Result, setDefaults } from "./util.ts"; /** projectにページをインポートする * * @param project - インポート先のprojectの名前 * @param data - インポートするページデータ */ -export async function importPages( +export const importPages = async ( project: string, data: ImportedData, - { sid, csrf }: ImportInit, + init: ExtendedOptions, ): Promise< Result -> { +> => { if (data.pages.length === 0) { return { ok: true, value: "No pages to import." }; } + const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); const formData = new FormData(); formData.append( "import-file", @@ -47,90 +35,80 @@ export async function importPages( }), ); formData.append("name", "undefined"); + const path = `https://${hostName}/api/page-data/import/${project}.json`; - csrf ??= await getCSRFToken(sid); - - const path = `https://scrapbox.io/api/page-data/import/${project}.json`; const res = await fetch( path, { method: "POST", headers: { - Cookie: cookie(sid), + ...(sid ? { Cookie: cookie(sid) } : {}), Accept: "application/json, text/plain, */*", - "X-CSRF-TOKEN": csrf, + "X-CSRF-TOKEN": csrf ?? await getCSRFToken(init), }, body: formData, }, ); if (!res.ok) { - if (res.status === 503) { - throw makeCustomError("ServerError", "503 Service Unavailable"); - } - const value = tryToErrorLike(await res.text()); + const text = await res.json(); + const value = tryToErrorLike(text); if (!value) { - throw makeCustomError( - "UnexpectedError", - `Unexpected error has occuerd when fetching "${path}"`, - ); + throw new UnexpectedResponseError({ + path: new URL(path), + ...res, + body: await res.text(), + }); } return { ok: false, value }; } + const { message } = (await res.json()) as { message: string }; return { ok: true, value: message }; -} +}; /** `exportPages`の認証情報 */ -export interface ExportInit { - /** connect.sid */ sid: string; +export interface ExportInit + extends BaseOptions { /** whether to includes metadata */ metadata: withMetadata; } /** projectの全ページをエクスポートする * * @param project exportしたいproject */ -export async function exportPages( +export const exportPages = async ( project: string, - { sid, metadata }: ExportInit, + init: ExportInit, ): Promise< Result< ExportedData, NotFoundError | NotPrivilegeError | NotLoggedInError > -> { +> => { + const { sid, hostName, fetch, metadata } = setDefaults(init ?? {}); const path = - `https://scrapbox.io/api/page-data/export/${project}.json?metadata=${metadata}`; + `https://${hostName}/api/page-data/export/${project}.json?metadata=${metadata}`; const res = await fetch( path, - { - headers: { - Cookie: cookie(sid), - }, - }, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); if (!res.ok) { - const error = (await res.json()); - return { ok: false, ...error }; - } - if (!res.ok) { - const value = tryToErrorLike(await res.text()) as - | false - | NotFoundError - | NotPrivilegeError - | NotLoggedInError; + const text = await res.json(); + const value = tryToErrorLike(text); if (!value) { - throw makeCustomError( - "UnexpectedError", - `Unexpected error has occuerd when fetching "${path}"`, - ); + throw new UnexpectedResponseError({ + path: new URL(path), + ...res, + body: await res.text(), + }); } return { ok: false, - value, + value: value as NotFoundError | NotPrivilegeError | NotLoggedInError, }; } + const value = (await res.json()) as ExportedData; return { ok: true, value }; -} +}; diff --git a/rest/pages.ts b/rest/pages.ts index 84a0c55..361a17f 100644 --- a/rest/pages.ts +++ b/rest/pages.ts @@ -5,14 +5,15 @@ import type { Page, PageList, } from "../deps/scrapbox.ts"; -import { cookie, makeCustomError, tryToErrorLike } from "./utils.ts"; +import { cookie } from "./auth.ts"; +import { UnexpectedResponseError } from "./error.ts"; +import { tryToErrorLike } from "../is.ts"; import { encodeTitleURI } from "../title.ts"; -import type { Result } from "./utils.ts"; +import { BaseOptions, Result, setDefaults } from "./util.ts"; /** Options for `getPage()` */ -export interface GetPageOption { +export interface GetPageOption extends BaseOptions { /** use `followRename` */ followRename?: boolean; - /** connect.sid */ sid?: string; } /** 指定したページのJSONデータを取得する * @@ -20,7 +21,7 @@ export interface GetPageOption { * @param title 取得したいページのtitle 大文字小文字は問わない * @param options オプション */ -export async function getPage( +export const getPage = async ( project: string, title: string, options?: GetPageOption, @@ -29,45 +30,39 @@ export async function getPage( Page, NotFoundError | NotLoggedInError | NotMemberError > -> { - const path = `https://scrapbox.io/api/pages/${project}/${ +> => { + const { sid, hostName, fetch, followRename } = setDefaults(options ?? {}); + const path = `https://${hostName}/api/pages/${project}/${ encodeTitleURI(title) - }?followRename=${options?.followRename ?? true}`; - + }?followRename=${followRename ?? true}`; const res = await fetch( path, - options?.sid - ? { - headers: { - Cookie: cookie(options.sid), - }, - } - : undefined, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); - if (!res.ok) { - const value = tryToErrorLike(await res.text()) as - | false - | NotFoundError - | NotLoggedInError - | NotMemberError; + const text = await res.text(); + const value = tryToErrorLike(text); if (!value) { - throw makeCustomError( - "UnexpectedError", - `Unexpected error has occuerd when fetching "${path}"`, - ); + throw new UnexpectedResponseError({ + path: new URL(path), + ...res, + body: text, + }); } return { ok: false, - value, + value: value as + | NotFoundError + | NotLoggedInError + | NotMemberError, }; } const value = (await res.json()) as Page; return { ok: true, value }; -} +}; /** Options for `listPages()` */ -export interface ListPagesOption { +export interface ListPagesOption extends BaseOptions { /** the sort of page list to return * * @default "updated" @@ -91,15 +86,13 @@ export interface ListPagesOption { * @default 100 */ limit?: number; - /** connect.sid */ - sid?: string; } /** 指定したprojectのページを一覧する * * @param project 一覧したいproject * @param options オプション 取得範囲や並び順を決める */ -export async function listPages( +export const listPages = async ( project: string, options?: ListPagesOption, ): Promise< @@ -107,42 +100,38 @@ export async function listPages( PageList, NotFoundError | NotLoggedInError | NotMemberError > -> { - const { sort, limit, skip } = options ?? {}; +> => { + const { sid, hostName, fetch, sort, limit, skip } = setDefaults( + options ?? {}, + ); const params = new URLSearchParams(); if (sort !== undefined) params.append("sort", sort); if (limit !== undefined) params.append("limit", `${limit}`); if (skip !== undefined) params.append("skip", `${skip}`); - const path = `https://scrapbox.io/api/pages/${project}?${params.toString()}`; + const path = `https://${hostName}/api/pages/${project}?${params.toString()}`; const res = await fetch( path, - options?.sid - ? { - headers: { - Cookie: cookie(options.sid), - }, - } - : undefined, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); - if (!res.ok) { - const value = tryToErrorLike(await res.text()) as - | false - | NotFoundError - | NotLoggedInError - | NotMemberError; + const text = await res.text(); + const value = tryToErrorLike(text); if (!value) { - throw makeCustomError( - "UnexpectedError", - `Unexpected error has occuerd when fetching "${path}"`, - ); + throw new UnexpectedResponseError({ + path: new URL(path), + ...res, + body: text, + }); } return { ok: false, - value, + value: value as + | NotFoundError + | NotLoggedInError + | NotMemberError, }; } const value = (await res.json()) as PageList; return { ok: true, value }; -} +}; diff --git a/rest/profile.ts b/rest/profile.ts index 60b95fe..8bddc5d 100644 --- a/rest/profile.ts +++ b/rest/profile.ts @@ -1,33 +1,27 @@ import type { GuestUser, MemberUser } from "../deps/scrapbox.ts"; -import { cookie, makeCustomError } from "./utils.ts"; +import { cookie } from "./auth.ts"; +import { BaseOptions, setDefaults } from "./util.ts"; +import { UnexpectedResponseError } from "./error.ts"; -export interface ProfileInit { - /** connect.sid */ sid: string; -} /** get user profile * * @param init connect.sid etc. */ -export async function getProfile( - init?: ProfileInit, -): Promise { - const path = "https://scrapbox.io/api/users/me"; +export const getProfile = async ( + init?: BaseOptions, +): Promise => { + const { sid, hostName, fetch } = setDefaults(init ?? {}); + const path = `https://${hostName}/api/users/me`; const res = await fetch( path, - init?.sid - ? { - headers: { - Cookie: cookie(init.sid), - }, - } - : undefined, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); - if (!res.ok) { - throw makeCustomError( - "UnexpectedError", - `Unexpected error has occuerd when fetching "${path}"`, - ); + throw new UnexpectedResponseError({ + path: new URL(path), + ...res, + body: await res.text(), + }); } return (await res.json()) as MemberUser | GuestUser; -} +}; diff --git a/rest/project.ts b/rest/project.ts index 8886e0a..18e832e 100644 --- a/rest/project.ts +++ b/rest/project.ts @@ -1,9 +1,3 @@ -export async function getProjectId(project: string) { - const res = await fetch(`https://scrapbox.io/api/projects/${project}`); - const json = (await res.json()) as MemberProject; - return json.id; -} - import type { MemberProject, NotFoundError, @@ -11,51 +5,48 @@ import type { NotMemberError, NotMemberProject, } from "../deps/scrapbox.ts"; -import { cookie, makeCustomError, Result, tryToErrorLike } from "./utils.ts"; +import { cookie } from "./auth.ts"; +import { UnexpectedResponseError } from "./error.ts"; +import { tryToErrorLike } from "../is.ts"; +import { BaseOptions, Result, setDefaults } from "./util.ts"; -export interface ProjectInit { - /** connect.sid */ sid: string; -} /** get the project information * * @param project project name to get * @param init connect.sid etc. */ -export async function getProject( +export const getProject = async ( project: string, - init?: ProjectInit, + init?: BaseOptions, ): Promise< Result< MemberProject | NotMemberProject, NotFoundError | NotMemberError | NotLoggedInError > -> { - const path = `https://scrapbox.io/api/projects/${project}`; +> => { + const { sid, hostName, fetch } = setDefaults(init ?? {}); + const path = `https://${hostName}/api/projects/${project}`; const res = await fetch( path, - init?.sid - ? { - headers: { - Cookie: cookie(init.sid), - }, - } - : undefined, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); if (!res.ok) { - const value = tryToErrorLike(await res.json()) as - | false - | NotFoundError - | NotMemberError - | NotLoggedInError; + const text = await res.json(); + const value = tryToErrorLike(text); if (!value) { - throw makeCustomError( - "UnexpectedError", - `Unexpected error has occuerd when fetching "${path}"`, - ); + throw new UnexpectedResponseError({ + path: new URL(path), + ...res, + body: await res.text(), + }); } - return { ok: false, value }; + return { + ok: false, + value: value as NotFoundError | NotMemberError | NotLoggedInError, + }; } + const value = (await res.json()) as MemberProject | NotMemberProject; return { ok: true, value }; -} +}; diff --git a/rest/replaceLinks.ts b/rest/replaceLinks.ts index e8ead4a..83634eb 100644 --- a/rest/replaceLinks.ts +++ b/rest/replaceLinks.ts @@ -3,23 +3,10 @@ import type { NotLoggedInError, NotMemberError, } from "../deps/scrapbox.ts"; -import { - cookie, - getCSRFToken, - makeCustomError, - tryToErrorLike, -} from "./utils.ts"; -import type { Result } from "./utils.ts"; - -/** `replaceLinks`の認証情報 */ -export interface ReplaceLinksInit { - /** connect.sid */ sid: string; - /** CSRF token - * - * If it isn't set, automatically get CSRF token from scrapbox.io server. - */ - csrf?: string; -} +import { cookie, getCSRFToken } from "./auth.ts"; +import { UnexpectedResponseError } from "./error.ts"; +import { tryToErrorLike } from "../is.ts"; +import { ExtendedOptions, Result, setDefaults } from "./util.ts"; /** 指定したproject内の全てのリンクを書き換える * @@ -29,23 +16,22 @@ export interface ReplaceLinksInit { * @param project これで指定したproject内の全てのリンクが置換対象となる * @param from 置換前のリンク * @param to 置換後のリンク - * @param options connect.sidなど + * @param init connect.sidなど * @return 置換されたリンクがあったページの数 */ -export async function replaceLinks( +export const replaceLinks = async ( project: string, from: string, to: string, - init?: ReplaceLinksInit, + init?: ExtendedOptions, ): Promise< Result< number, NotFoundError | NotLoggedInError | NotMemberError > -> { - const path = `https://scrapbox.io/api/pages/${project}/replace/links`; - const sid = init?.sid; - const csrf = init?.csrf ?? await getCSRFToken(sid); +> => { + const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); + const path = `https://${hostName}/api/pages/${project}/replace/links`; const res = await fetch( path, @@ -53,36 +39,30 @@ export async function replaceLinks( method: "POST", headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": csrf, - ...(sid - ? { - Cookie: cookie(sid), - } - : {}), + "X-CSRF-TOKEN": csrf ?? await getCSRFToken(init), + ...(sid ? { Cookie: cookie(sid) } : {}), }, body: JSON.stringify({ from, to }), }, ); if (!res.ok) { - const value = tryToErrorLike(await res.text()) as - | false - | NotFoundError - | NotLoggedInError - | NotMemberError; + const text = await res.json(); + const value = tryToErrorLike(text); if (!value) { - throw makeCustomError( - "UnexpectedError", - `Unexpected error has occuerd when fetching "${path}"`, - ); + throw new UnexpectedResponseError({ + path: new URL(path), + ...res, + body: await res.text(), + }); } return { ok: false, - value, + value: value as NotFoundError | NotMemberError | NotLoggedInError, }; } // messageには"2 pages have been successfully updated!"というような文字列が入っているはず const { message } = (await res.json()) as { message: string }; return { ok: true, value: parseInt(message.match(/\d+/)?.[0] ?? "0") }; -} +}; diff --git a/rest/util.ts b/rest/util.ts new file mode 100644 index 0000000..2cd2a2a --- /dev/null +++ b/rest/util.ts @@ -0,0 +1,51 @@ +/** 正常値と異常値を格納する型 */ +export type Result = { ok: true; value: T } | { ok: false; value: E }; + +/** networkからdataをとってくる処理 + * + * interfaceは`fetch()`と同じ + */ +export type Fetch = ( + input: string | Request, + init?: RequestInit, +) => Promise; + +/** 全てのREST APIに共通するopitons */ +export interface BaseOptions { + /** connect.sid + * + * private projectのデータやscrapbox accountに紐付いたデータを取得する際に必要な認証情報 + */ + sid?: string; + + /** データの取得に使う処理 + * + * @default fetch + */ + fetch?: Fetch; + + /** REST APIのdomain + * + * オンプレ版scrapboxなどだと、scrapbox.io以外のhost nameになるので、予め変えられるようにしておく + * + * @default "scrapbox.io" + */ + hostName?: string; +} +/** BaseeOptionsにCSRF情報を入れたもの */ +export interface ExtendedOptions extends BaseOptions { + /** CSRF token + * + * If it isn't set, automatically get CSRF token from scrapbox.io server. + */ + csrf?: string; +} + +/** BaseOptionsの既定値を埋める */ +export const setDefaults = ( + options: T, +): Omit & Required> => { + const { fetch = globalThis.fetch, hostName = "scrapbox.io", ...rest } = + options; + return { fetch, hostName, ...rest }; +}; diff --git a/rest/utils.ts b/rest/utils.ts deleted file mode 100644 index c0c5ce6..0000000 --- a/rest/utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ErrorLike } from "../deps/scrapbox.ts"; -import { getProfile } from "./profile.ts"; - -// scrapbox.io内なら`window._csrf`にCSRF tokenが入っている -declare global { - interface Window { - _csrf?: string; - } -} - -/** HTTP headerのCookieに入れる文字列を作る - * - * @param sid connect.sidに入っている文字列 - */ -export const cookie = (sid: string) => `connect.sid=${sid}`; - -export type Result = { ok: true; value: T } | { ok: false; value: E }; -/** CSRF tokenを取得する - * - * @param sid - connect.sidに入っている文字列。不正な文字列を入れてもCSRF tokenを取得できるみたい - */ -export async function getCSRFToken( - sid?: string, -): Promise { - if (window._csrf) return window._csrf; - - const user = await getProfile(sid ? { sid } : undefined); - return user.csrfToken; -} - -// cf. https://blog.uhy.ooo/entry/2021-04-09/typescript-is-any-as/#%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E5%AE%9A%E7%BE%A9%E5%9E%8B%E3%82%AC%E3%83%BC%E3%83%89%E3%81%AE%E5%BC%95%E6%95%B0%E3%81%AE%E5%9E%8B%E3%82%92%E3%81%A9%E3%81%86%E3%81%99%E3%82%8B%E3%81%8B -function isNotNullish(data: unknown): data is Record { - return data != null; -} -function isErrorLike(e: unknown): e is ErrorLike { - if (!isNotNullish(e)) return false; - return (e.name === undefined || typeof e.name === "string") && - typeof e.message === "string"; -} -/** 与えられたobjectもしくはJSONテキストをErrorLikeに変換できるかどうか試す - * - * @param e 試したいobjectもしくはテキスト - */ -export function tryToErrorLike(e: unknown): false | ErrorLike { - try { - const json = typeof e === "string" ? JSON.parse(e) : e; - if (!isErrorLike(json)) return false; - return json; - } catch (e2: unknown) { - if (e2 instanceof SyntaxError) return false; - throw e2; - } -} - -/** classを使わずにカスタムエラーを作る */ -export function makeCustomError(name: string, message: string) { - const error = new Error(); - error.name = name; - error.message = message; - return error; -}