From e2ec5dd6e313ea4ffde5361d1207e1d90c8d1d7e Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:47:46 +0900 Subject: [PATCH 01/16] feat(api): Implement the `/api/pages/:project/:title` endpoint --- api.ts | 1 + api/pages.ts | 1 + api/pages/project.ts | 1 + api/pages/project/title.ts | 84 ++++++++++++++++ deno.jsonc | 8 +- deno.lock | 54 ++++++++++- json_compatible.ts | 123 +++++++++++++++++++++++ targeted_response.ts | 193 +++++++++++++++++++++++++++++++++++++ util.ts | 76 +++++++++++++++ 9 files changed, 537 insertions(+), 4 deletions(-) create mode 100644 api.ts create mode 100644 api/pages.ts create mode 100644 api/pages/project.ts create mode 100644 api/pages/project/title.ts create mode 100644 json_compatible.ts create mode 100644 targeted_response.ts create mode 100644 util.ts diff --git a/api.ts b/api.ts new file mode 100644 index 0000000..9511142 --- /dev/null +++ b/api.ts @@ -0,0 +1 @@ +export * as pages from "./api/pages.ts"; diff --git a/api/pages.ts b/api/pages.ts new file mode 100644 index 0000000..ee9af7c --- /dev/null +++ b/api/pages.ts @@ -0,0 +1 @@ +export * as project from "./pages/project.ts"; diff --git a/api/pages/project.ts b/api/pages/project.ts new file mode 100644 index 0000000..2b9a3ab --- /dev/null +++ b/api/pages/project.ts @@ -0,0 +1 @@ +export * as title from "./project/title.ts"; diff --git a/api/pages/project/title.ts b/api/pages/project/title.ts new file mode 100644 index 0000000..64225c1 --- /dev/null +++ b/api/pages/project/title.ts @@ -0,0 +1,84 @@ +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, + Page, +} from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../../targeted_response.ts"; +import { type BaseOptions, setDefaults } from "../../../util.ts"; +import { encodeTitleURI } from "../../../title.ts"; +import { cookie } from "../../../rest/auth.ts"; + +/** Options for {@linkcode getPage} */ +export interface GetPageOption + extends BaseOptions { + /** use `followRename` */ + followRename?: boolean; + + /** project ids to get External links */ + projects?: string[]; +} + +/** Constructs a request for the `/api/pages/:project/:title` endpoint + * + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching page data + */ +export const makeGetRequest = ( + project: string, + title: string, + options?: GetPageOption, +): Request => { + const { sid, hostName, followRename, projects } = setDefaults(options ?? {}); + + const params = new URLSearchParams([ + ["followRename", `${followRename ?? true}`], + ...(projects?.map?.((id) => ["projects", id]) ?? []), + ]); + + return new Request( + `https://${hostName}/api/pages/${project}/${ + encodeTitleURI(title) + }?${params}`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** Retrieves JSON data for a specified page + * + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options Additional configuration options for the request + * @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing: + * - Success: The page data in JSON format + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Page not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + */ +export const get = ( + project: string, + title: string, + options?: GetPageOption, +): Promise< + | ResponseOfEndpoint<{ + 200: Page; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }> + | (undefined extends R ? undefined : never) +> => + setDefaults(options ?? {}).fetch( + makeGetRequest(project, title, options), + ) as Promise< + | ResponseOfEndpoint<{ + 200: Page; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }> + | (undefined extends R ? undefined : never) + >; diff --git a/deno.jsonc b/deno.jsonc index 26a1717..ddc2399 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -20,7 +20,11 @@ "./rest": "./rest/mod.ts", "./text": "./text.ts", "./title": "./title.ts", - "./websocket": "./websocket/mod.ts" + "./websocket": "./websocket/mod.ts", + "./unstable-api": "./api.ts", + "./unstable-api/pages": "./api/pages.ts", + "./unstable-api/pages/project": "./api/pages/project.ts", + "./unstable-api/pages/project/title": "./api/pages/project/title.ts" }, "imports": { "@core/unknownutil": "jsr:@core/unknownutil@^4.0.0", @@ -34,7 +38,9 @@ "@std/assert": "jsr:@std/assert@1", "@std/async": "jsr:@std/async@1", "@std/encoding": "jsr:@std/encoding@1", + "@std/http": "jsr:@std/http@^1.0.13", "@std/json": "jsr:@std/json@^1.0.0", + "@std/testing": "jsr:@std/testing@^1.0.9", "@std/testing/snapshot": "jsr:@std/testing@1/snapshot", "@takker/md5": "jsr:@takker/md5@0.1", "@takker/onp": "./vendor/raw.githubusercontent.com/takker99/onp/0.0.1/mod.ts", diff --git a/deno.lock b/deno.lock index c85995c..fe068a5 100644 --- a/deno.lock +++ b/deno.lock @@ -8,14 +8,23 @@ "jsr:@std/assert@1": "1.0.11", "jsr:@std/assert@^1.0.10": "1.0.11", "jsr:@std/async@1": "1.0.10", + "jsr:@std/cli@^1.0.12": "1.0.13", "jsr:@std/data-structures@^1.0.6": "1.0.6", "jsr:@std/encoding@1": "1.0.7", - "jsr:@std/fs@^1.0.9": "1.0.9", + "jsr:@std/encoding@^1.0.7": "1.0.7", + "jsr:@std/fmt@^1.0.5": "1.0.5", + "jsr:@std/fs@^1.0.9": "1.0.13", + "jsr:@std/html@^1.0.3": "1.0.3", + "jsr:@std/http@^1.0.13": "1.0.13", "jsr:@std/internal@^1.0.5": "1.0.5", "jsr:@std/json@1": "1.0.1", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.4": "1.0.4", "jsr:@std/path@^1.0.8": "1.0.8", - "jsr:@std/streams@^1.0.7": "1.0.8", + "jsr:@std/streams@^1.0.7": "1.0.9", + "jsr:@std/streams@^1.0.9": "1.0.9", "jsr:@std/testing@1": "1.0.9", + "jsr:@std/testing@^1.0.9": "1.0.9", "jsr:@takker/md5@0.1": "0.1.0", "npm:option-t@51": "51.0.1", "npm:socket.io-client@^4.7.5": "4.8.1" @@ -39,33 +48,70 @@ "@std/async@1.0.10": { "integrity": "2ff1b1c7d33d1416159989b0f69e59ec7ee8cb58510df01e454def2108b3dbec" }, + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + }, "@std/data-structures@1.0.6": { "integrity": "76a7fd8080c66604c0496220a791860492ab21a04a63a969c0b9a0609bbbb760" }, "@std/encoding@1.0.7": { "integrity": "f631247c1698fef289f2de9e2a33d571e46133b38d042905e3eac3715030a82d" }, + "@std/fmt@1.0.5": { + "integrity": "0cfab43364bc36650d83c425cd6d99910fc20c4576631149f0f987eddede1a4d" + }, "@std/fs@1.0.9": { "integrity": "3eef7e3ed3d317b29432c7dcb3b20122820dbc574263f721cb0248ad91bad890", "dependencies": [ "jsr:@std/path" ] }, + "@std/fs@1.0.13": { + "integrity": "756d3ff0ade91c9e72b228e8012b6ff00c3d4a4ac9c642c4dac083536bf6c605", + "dependencies": [ + "jsr:@std/path" + ] + }, + "@std/html@1.0.3": { + "integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988" + }, + "@std/http@1.0.13": { + "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding@^1.0.7", + "jsr:@std/fmt", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path", + "jsr:@std/streams@^1.0.9" + ] + }, "@std/internal@1.0.5": { "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" }, "@std/json@1.0.1": { "integrity": "1f0f70737e8827f9acca086282e903677bc1bb0c8ffcd1f21bca60039563049f", "dependencies": [ - "jsr:@std/streams" + "jsr:@std/streams@^1.0.7" ] }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.4": { + "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" + }, "@std/path@1.0.8": { "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" }, "@std/streams@1.0.8": { "integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3" }, + "@std/streams@1.0.9": { + "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035" + }, "@std/testing@1.0.9": { "integrity": "9bdd4ac07cb13e7594ac30e90f6ceef7254ac83a9aeaa089be0008f33aab5cd4", "dependencies": [ @@ -141,8 +187,10 @@ "jsr:@std/assert@1", "jsr:@std/async@1", "jsr:@std/encoding@1", + "jsr:@std/http@^1.0.13", "jsr:@std/json@1", "jsr:@std/testing@1", + "jsr:@std/testing@^1.0.9", "jsr:@takker/md5@0.1", "npm:option-t@51", "npm:socket.io-client@^4.7.5" diff --git a/json_compatible.ts b/json_compatible.ts new file mode 100644 index 0000000..3ffb685 --- /dev/null +++ b/json_compatible.ts @@ -0,0 +1,123 @@ +import type { JsonValue } from "@std/json/types"; +import type { IsAny } from "@std/testing/types"; +export type { IsAny, JsonValue }; + +/** + * Check if a property {@linkcode K} is optional in {@linkcode T}. + * + * ```ts + * import type { Assert } from "@std/testing/types"; + * + * type _1 = Assert, true>; + * type _2 = Assert, true>; + * type _3 = Assert, true>; + * type _4 = Assert, false>; + * type _5 = Assert, false>; + * type _6 = Assert, false>; + * ``` + * @internal + * + * @see https://dev.to/zirkelc/typescript-how-to-check-for-optional-properties-3192 + */ +export type IsOptional = + Record extends Pick ? true : false; + +/** + * A type that is compatible with JSON. + * + * ```ts + * import type { JsonValue } from "@std/json/types"; + * import { assertType } from "@std/testing/types"; + * + * type IsJsonCompatible = [T] extends [JsonCompatible] ? true : false; + * + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(false); + * // deno-lint-ignore no-explicit-any + * assertType>(false); + * assertType>(false); + * assertType>(false); + * // deno-lint-ignore ban-types + * assertType>(false); + * assertType void>>(false); + * assertType>(false); + * assertType>(false); + * + * assertType>(true); + * // deno-lint-ignore ban-types + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(true); + * assertType>(false); + * assertType>(false); + * assertType>(true); + * assertType>(true); + * assertType>(false); + * assertType>(true); + * assertType>(false); + * assertType>(true); + * assertType>(false); + * assertType>(true); + * assertType>(true); + * // deno-lint-ignore no-explicit-any + * assertType>(false); + * assertType>(false); + * // deno-lint-ignore ban-types + * assertType>(false); + * // deno-lint-ignore no-explicit-any + * assertType any }>>(false); + * // deno-lint-ignore no-explicit-any + * assertType any) | number }>>(false); + * // deno-lint-ignore no-explicit-any + * assertType any }>>(false); + * class A { + * a = 34; + * } + * assertType>(true); + * class B { + * fn() { + * return "hello"; + * }; + * } + * assertType>(false); + * + * assertType>(true); + * assertType void }>>(false); + * + * assertType>(true); + * interface D { + * aa: string; + * } + * assertType>(true); + * interface E { + * a: D; + * } + * assertType>(true); + * interface F { + * _: E; + * } + * assertType>(true); + * ``` + * + * @see This implementation is heavily inspired by https://github.com/microsoft/TypeScript/issues/1897#issuecomment-580962081 . + */ +export type JsonCompatible = + // deno-lint-ignore ban-types + [Extract] extends [never] ? { + [K in keyof T]: [IsAny] extends [true] ? never + : T[K] extends JsonValue ? T[K] + : [IsOptional] extends [true] + ? JsonCompatible> | Extract + : undefined extends T[K] ? never + : JsonCompatible; + } + : never; diff --git a/targeted_response.ts b/targeted_response.ts new file mode 100644 index 0000000..400c7e9 --- /dev/null +++ b/targeted_response.ts @@ -0,0 +1,193 @@ +import type { StatusCode, SuccessfulStatus } from "@std/http/status"; +import type { JsonCompatible } from "./json_compatible.ts"; + +export type { StatusCode, SuccessfulStatus }; + +/** + * Maps a record of status codes and response body types to a union of {@linkcode TargetedResponse}. + * + * ```ts + * import type { AssertTrue, IsExact } from "@std/testing/types"; + * + * type MappedResponse = ResponseOfEndpoint<{ + * 200: { success: true }, + * 404: { error: "Not Found" }, + * 500: string, + * }>; + * type _ = AssertTrue< + * IsExact< + * MappedResponse, + * | TargetedResponse<200, { success: true }> + * | TargetedResponse<404, { error: "Not Found" }> + * | TargetedResponse<500, string> + * | TargetedResponse<100, string> + * | TargetedResponse<101, string> + * | TargetedResponse<102, string> + * | TargetedResponse<103, string> + * | TargetedResponse<201, string> + * | TargetedResponse<202, string> + * | TargetedResponse<203, string> + * | TargetedResponse<204, string> + * | TargetedResponse<205, string> + * | TargetedResponse<206, string> + * | TargetedResponse<207, string> + * | TargetedResponse<208, string> + * | TargetedResponse<226, string> + * | TargetedResponse<300, string> + * | TargetedResponse<301, string> + * | TargetedResponse<302, string> + * | TargetedResponse<303, string> + * | TargetedResponse<304, string> + * | TargetedResponse<305, string> + * | TargetedResponse<307, string> + * | TargetedResponse<308, string> + * | TargetedResponse<400, string> + * | TargetedResponse<401, string> + * | TargetedResponse<402, string> + * | TargetedResponse<403, string> + * | TargetedResponse<405, string> + * | TargetedResponse<406, string> + * | TargetedResponse<407, string> + * | TargetedResponse<408, string> + * | TargetedResponse<409, string> + * | TargetedResponse<410, string> + * | TargetedResponse<411, string> + * | TargetedResponse<412, string> + * | TargetedResponse<413, string> + * | TargetedResponse<414, string> + * | TargetedResponse<415, string> + * | TargetedResponse<416, string> + * | TargetedResponse<417, string> + * | TargetedResponse<418, string> + * | TargetedResponse<421, string> + * | TargetedResponse<422, string> + * | TargetedResponse<423, string> + * | TargetedResponse<424, string> + * | TargetedResponse<425, string> + * | TargetedResponse<426, string> + * | TargetedResponse<428, string> + * | TargetedResponse<429, string> + * | TargetedResponse<431, string> + * | TargetedResponse<451, string> + * | TargetedResponse<500, string> + * | TargetedResponse<501, string> + * | TargetedResponse<502, string> + * | TargetedResponse<503, string> + * | TargetedResponse<504, string> + * | TargetedResponse<505, string> + * | TargetedResponse<506, string> + * | TargetedResponse<507, string> + * | TargetedResponse<508, string> + * | TargetedResponse<510, string> + * | TargetedResponse<511, string> + * > + * >; + * ``` + */ +export type ResponseOfEndpoint< + ResponseBodyMap extends Record = Record, +> = { + [Status in StatusCode | keyof ResponseBodyMap]: Status extends number + ? ResponseBodyMap[Status] extends + | string + | Exclude< + JsonCompatible, + string | number | boolean | null + > + | Uint8Array + | FormData + | Blob ? TargetedResponse + : Status extends StatusCode ? TargetedResponse + : never + : never; +}[StatusCode | keyof ResponseBodyMap]; + +/** + * Type-safe {@linkcode Response} object + * + * @typeParam Status Available [HTTP status codes](https://developer.mozilla.org/docs/Web/HTTP/Status) + * @typeParam Body response body type returned by {@linkcode TargetedResponse.text}, {@linkcode TargetedResponse.json} or {@linkcode TargetedResponse.formData} + */ +export interface TargetedResponse< + Status extends number, + Body extends + | string + | Exclude, string | number | boolean | null> + | Uint8Array + | FormData + | Blob, +> extends globalThis.Response { + /** + * [HTTP status code](https://developer.mozilla.org/docs/Web/HTTP/Status) + */ + readonly status: Status; + + /** + * Whether the response is successful or not + * + * The same as {@linkcode https://developer.mozilla.org/docs/Web/API/Response/ok | Response.ok}. + * + * ```ts + * import type { Assert } from "@std/testing/types"; + * + * type _1 = Assert["ok"], true>; + * type _2 = Assert["ok"], true>; + * type _3 = Assert["ok"], true>; + * type _4 = Assert["ok"], false>; + * type _5 = Assert["ok"], false>; + * type _6 = Assert["ok"], false>; + * type _7 = Assert["ok"], false>; + * type _8 = Assert["ok"], boolean>; + * ``` + */ + readonly ok: Status extends SuccessfulStatus ? true + : Status extends Exclude ? false + : boolean; + + /** + * The same as {@linkcode https://developer.mozilla.org/docs/Web/API/Response/text | Response.text} but with type safety + * + * ```ts + * import type { AssertTrue, IsExact } from "@std/testing/types"; + * + * type _1 = AssertTrue["text"], () => Promise>>; + * type _2 = AssertTrue["text"], () => Promise<"result">>>; + * type _3 = AssertTrue["text"], () => Promise<"state1" | "state2">>>; + * type _4 = AssertTrue["text"], () => Promise>>; + * type _5 = AssertTrue["text"], () => Promise>>; + * type _6 = AssertTrue["text"], () => Promise>>; + * type _7 = AssertTrue["text"], () => Promise>>; + * type _8 = AssertTrue["text"], () => Promise>>; + * type _9 = AssertTrue["text"], () => Promise>>; + * ``` + */ + text(): [Body] extends [string] ? Promise + : [Body] extends [Exclude, number | boolean | null>] + ? Promise + : Promise; + /** + * The same as {@linkcode https://developer.mozilla.org/docs/Web/API/Response/json | Response.json} but with type safety + * + * ```ts + * import type { AssertTrue, IsExact } from "@std/testing/types"; + * + * type _1 = AssertTrue["json"], () => Promise<{ data: { id: string; name: string; }; }>>>; + * type _4 = AssertTrue["json"], () => Promise>>; + * type _5 = AssertTrue["json"], () => Promise>>; + * type _6 = AssertTrue["json"], () => Promise>>; + * type _7 = AssertTrue["json"], () => Promise>>; + * type _3 = AssertTrue["json"], () => Promise>>; + * type _8 = AssertTrue["json"], () => Promise>>; + * type _9 = AssertTrue["json"], () => Promise>>; + * ``` + */ + json(): [Body] extends + [Exclude, string | number | boolean | null>] + ? Promise + : Promise; + + /** + * The same as {@linkcode https://developer.mozilla.org/docs/Web/API/Response/formData | Response.formData} but with type safety + */ + formData(): Body extends FormData ? Promise : Promise; +} diff --git a/util.ts b/util.ts new file mode 100644 index 0000000..346a56c --- /dev/null +++ b/util.ts @@ -0,0 +1,76 @@ +/** + * Timestamp string whose format is `YYYY-MM-DDTHH:mm:ssZ` + */ +export type Timestamp = string; + +/** Represents {@linkcode fetch} + * + * This type can return `undefined`, which is useful for implementing `fetch` using Cache API. + */ +export type Fetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +/** Common options shared across all REST API endpoints + * + * These options configure authentication, network behavior, and host settings + * for all API requests in the library. + */ +export interface BaseOptions { + /** Scrapbox session ID (connect.sid) + * + * Authentication token required to access: + * - Private project data + * - User-specific data linked to Scrapbox accounts + * - Protected API endpoints + */ + sid?: string; + + /** Custom fetch implementation for making HTTP requests + * + * Allows overriding the default fetch behavior for testing + * or custom networking requirements. + * + * @default {globalThis.fetch} + */ + fetch?: Fetch; + + /** Domain for REST API endpoints + * + * Configurable host name for API requests. This allows using the library + * with self-hosted Scrapbox instances or other custom deployments that + * don't use the default scrapbox.io domain. + * + * @default {"scrapbox.io"} + */ + hostName?: string; +} + +/** Options for Gyazo API which requires OAuth */ +export interface OAuthOptions + extends BaseOptions { + /** an access token associated with the Gyazo user account */ + accessToken: string; +} + +/** Set default values for {@linkcode BaseOptions} + * + * Ensures all required fields have appropriate default values while + * preserving any user-provided options. + * + * @param options - User-provided {@linkcode Options} to merge with defaults + * @returns {@linkcode Options} object with all required fields populated + * + * @internal + */ +export const setDefaults = < + // deno-lint-ignore no-explicit-any + T extends BaseOptions = BaseOptions, +>( + options: T, +): Omit & Required> => { + const { fetch = globalThis.fetch, hostName = "scrapbox.io", ...rest } = + options; + return { fetch, hostName, ...rest }; +}; From 88bad853e5a1872514bdac125f6db4028eca73a3 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:48:17 +0900 Subject: [PATCH 02/16] feat(api): Implement the `/api/pages/:project` endpoint --- api/pages/project.ts | 126 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/api/pages/project.ts b/api/pages/project.ts index 2b9a3ab..2d36494 100644 --- a/api/pages/project.ts +++ b/api/pages/project.ts @@ -1 +1,127 @@ +import type { + BasePage, + NotFoundError, + NotLoggedInError, + NotMemberError, + PageList, +} from "@cosense/types/rest"; +import { type BaseOptions, setDefaults } from "../../util.ts"; +import { cookie } from "../../rest/auth.ts"; +import type { ResponseOfEndpoint } from "../../targeted_response.ts"; + +/** Options for {@linkcode listPages} */ +export interface ListPagesOption + extends BaseOptions { + /** the sort of page list to return + * + * @default {"updated"} + */ + sort?: + | "updatedWithMe" + | "updated" + | "created" + | "accessed" + | "pageRank" + | "linked" + | "views" + | "title"; + /** the index getting page list from + * + * @default {0} + */ + skip?: number; + /** threshold of the length of page list + * + * @default {100} + */ + limit?: number; +} + +/** Constructs a request for the `/api/pages/:project` endpoint + * + * @param project The project name to list pages from + * @param options - Additional configuration options (sorting, pagination, etc.) + * @returns A {@linkcode Request} object for fetching pages data + */ +export const makeGetRequest = ( + project: string, + options?: ListPagesOption, +): Request => { + const { sid, hostName, 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}`); + + return new Request( + `https://${hostName}/api/pages/${project}?${params}`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** Lists pages from a specified project + * + * @param project The project name to list pages from + * @param options Configuration options for pagination and sorting + * @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing: + * - Success: The page data in JSON format + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Page not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + */ +export const get = ( + project: string, + options?: ListPagesOption, +): Promise< + | ResponseOfEndpoint<{ + 200: PageList; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }> + | (undefined extends R ? undefined : never) +> => + setDefaults(options ?? {}).fetch( + makeGetRequest(project, options), + ) as Promise< + | ResponseOfEndpoint<{ + 200: PageList; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }> + | (undefined extends R ? undefined : never) + >; + +/** + * Lists pages from a given `project` with pagination + * + * @param project The project name to list pages from + * @param options Configuration options for pagination and sorting + * @throws {HTTPError} If any requests in the pagination sequence fail + */ +export async function* list( + project: string, + options?: ListPagesOption, +): AsyncGenerator { + const props = { ...(options ?? {}), skip: options?.skip ?? 0 }; + while (true) { + const response = await get(project, props); + if (response.status !== 200) { + throw new Error(response.statusText, { cause: response }) as HTTPError; + } + const list = await response.json(); + yield* list.pages; + props.skip += props.limit ?? 100; + if (list.skip + list.limit >= list.count) break; + } +} + export * as title from "./project/title.ts"; + +export interface HTTPError extends Error { + readonly cause: Response; +} From a37eb3ab5c86a49be3be448fa9437aa52c941bef Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Wed, 19 Mar 2025 08:16:45 +0900 Subject: [PATCH 03/16] refactor(api): Include `R` into `ResponseOfEndpoint` --- api/pages/project.ts | 10 ++++------ api/pages/project/title.ts | 10 ++++------ targeted_response.ts | 33 ++++++++++++++++++--------------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/api/pages/project.ts b/api/pages/project.ts index 2d36494..ff3b1b5 100644 --- a/api/pages/project.ts +++ b/api/pages/project.ts @@ -76,24 +76,22 @@ export const get = ( project: string, options?: ListPagesOption, ): Promise< - | ResponseOfEndpoint<{ + ResponseOfEndpoint<{ 200: PageList; 404: NotFoundError; 401: NotLoggedInError; 403: NotMemberError; - }> - | (undefined extends R ? undefined : never) + }, R> > => setDefaults(options ?? {}).fetch( makeGetRequest(project, options), ) as Promise< - | ResponseOfEndpoint<{ + ResponseOfEndpoint<{ 200: PageList; 404: NotFoundError; 401: NotLoggedInError; 403: NotMemberError; - }> - | (undefined extends R ? undefined : never) + }, R> >; /** diff --git a/api/pages/project/title.ts b/api/pages/project/title.ts index 64225c1..b082981 100644 --- a/api/pages/project/title.ts +++ b/api/pages/project/title.ts @@ -63,22 +63,20 @@ export const get = ( title: string, options?: GetPageOption, ): Promise< - | ResponseOfEndpoint<{ + ResponseOfEndpoint<{ 200: Page; 404: NotFoundError; 401: NotLoggedInError; 403: NotMemberError; - }> - | (undefined extends R ? undefined : never) + }, R> > => setDefaults(options ?? {}).fetch( makeGetRequest(project, title, options), ) as Promise< - | ResponseOfEndpoint<{ + ResponseOfEndpoint<{ 200: Page; 404: NotFoundError; 401: NotLoggedInError; 403: NotMemberError; - }> - | (undefined extends R ? undefined : never) + }, R> >; diff --git a/targeted_response.ts b/targeted_response.ts index 400c7e9..303877f 100644 --- a/targeted_response.ts +++ b/targeted_response.ts @@ -86,21 +86,24 @@ export type { StatusCode, SuccessfulStatus }; */ export type ResponseOfEndpoint< ResponseBodyMap extends Record = Record, -> = { - [Status in StatusCode | keyof ResponseBodyMap]: Status extends number - ? ResponseBodyMap[Status] extends - | string - | Exclude< - JsonCompatible, - string | number | boolean | null - > - | Uint8Array - | FormData - | Blob ? TargetedResponse - : Status extends StatusCode ? TargetedResponse - : never - : never; -}[StatusCode | keyof ResponseBodyMap]; + R extends Response | undefined = Response, +> = + | { + [Status in StatusCode | keyof ResponseBodyMap]: Status extends number + ? ResponseBodyMap[Status] extends + | string + | Exclude< + JsonCompatible, + string | number | boolean | null + > + | Uint8Array + | FormData + | Blob ? TargetedResponse + : Status extends StatusCode ? TargetedResponse + : never + : never; + }[StatusCode | keyof ResponseBodyMap] + | (undefined extends R ? undefined : never); /** * Type-safe {@linkcode Response} object From ed57c55d5bf8d782a54f316c7c876395201ee098 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Wed, 19 Mar 2025 08:38:15 +0900 Subject: [PATCH 04/16] feat(api): Enhance error handling in `listPages` function and introduce `TypedError` interface --- api/pages/project.ts | 27 +++++++++++++++++++------- error.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 error.ts diff --git a/api/pages/project.ts b/api/pages/project.ts index ff3b1b5..952f51f 100644 --- a/api/pages/project.ts +++ b/api/pages/project.ts @@ -8,6 +8,12 @@ import type { import { type BaseOptions, setDefaults } from "../../util.ts"; import { cookie } from "../../rest/auth.ts"; import type { ResponseOfEndpoint } from "../../targeted_response.ts"; +import { + type HTTPError, + makeError, + makeHTTPError, + type TypedError, +} from "../../error.ts"; /** Options for {@linkcode listPages} */ export interface ListPagesOption @@ -99,7 +105,7 @@ export const get = ( * * @param project The project name to list pages from * @param options Configuration options for pagination and sorting - * @throws {HTTPError} If any requests in the pagination sequence fail + * @throws {HTTPError | TypedError<"NotLoggedInError" | "NotMemberError" | "NotFoundError">} If any requests in the pagination sequence fail */ export async function* list( project: string, @@ -108,8 +114,19 @@ export async function* list( const props = { ...(options ?? {}), skip: options?.skip ?? 0 }; while (true) { const response = await get(project, props); - if (response.status !== 200) { - throw new Error(response.statusText, { cause: response }) as HTTPError; + switch (response.status) { + case 200: + break; + case 401: + case 403: + case 404: { + const error = await response.json(); + throw makeError(error.name, error.message) satisfies TypedError< + "NotLoggedInError" | "NotMemberError" | "NotFoundError" + >; + } + default: + throw makeHTTPError(response) satisfies HTTPError; } const list = await response.json(); yield* list.pages; @@ -119,7 +136,3 @@ export async function* list( } export * as title from "./project/title.ts"; - -export interface HTTPError extends Error { - readonly cause: Response; -} diff --git a/error.ts b/error.ts new file mode 100644 index 0000000..8b2b56f --- /dev/null +++ b/error.ts @@ -0,0 +1,46 @@ +export interface TypedError + extends Error { + /** + * The error name + */ + readonly name: Name; + + /** + * The error cause + */ + readonly cause?: Cause; +} + +export const makeError = ( + name: Name, + message?: string, + cause?: Cause, +): TypedError => { + // from https://stackoverflow.com/a/43001581 + type Writeable = { -readonly [P in keyof T]: T[P] }; + + const error = new Error(message, { cause }) as Writeable< + TypedError + >; + error.name = name; + return error; +}; + +export interface HTTPError + extends TypedError<"HTTPError", Cause> { + readonly response: Response; +} + +export const makeHTTPError = ( + response: Response, + message?: string, + cause?: Cause, +): HTTPError => { + // from https://stackoverflow.com/a/43001581 + type Writeable = { -readonly [P in keyof T]: T[P] }; + + const error = new Error(message, { cause }) as Writeable>; + error.name = "HTTPError"; + error.response = response; + return error; +}; From 872dd54f9089d51d372b21f2a25b9b37033fc3de Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Wed, 19 Mar 2025 08:53:26 +0900 Subject: [PATCH 05/16] feat(api): Implement the `/api/users/me` endpoint --- api.ts | 1 + api/users.ts | 1 + api/users/me.ts | 36 ++++++++++++++++++++++++++++++++++++ deno.jsonc | 4 +++- 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 api/users.ts create mode 100644 api/users/me.ts diff --git a/api.ts b/api.ts index 9511142..52456b7 100644 --- a/api.ts +++ b/api.ts @@ -1 +1,2 @@ export * as pages from "./api/pages.ts"; +export * as users from "./api/users.ts"; diff --git a/api/users.ts b/api/users.ts new file mode 100644 index 0000000..508ba8d --- /dev/null +++ b/api/users.ts @@ -0,0 +1 @@ +export * as me from "./users/me.ts"; diff --git a/api/users/me.ts b/api/users/me.ts new file mode 100644 index 0000000..162f672 --- /dev/null +++ b/api/users/me.ts @@ -0,0 +1,36 @@ +import type { GuestUser, MemberUser } from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../targeted_response.ts"; +import { type BaseOptions, setDefaults } from "../../util.ts"; +import { cookie } from "../../rest/auth.ts"; + +/** Constructs a request for the `/api/users/me endpoint` + * + * This endpoint retrieves the current user's profile information, + * which can be either a {@linkcode MemberUser} or {@linkcode GuestUser} profile. + * + * @param init - Options including `connect.sid` (session ID) and other configuration + * @returns A {@linkcode Request} object for fetching user profile data + */ +export const makeGetRequest = ( + init?: BaseOptions, +): Request => { + const { sid, hostName } = setDefaults(init ?? {}); + return new Request( + `https://${hostName}/api/users/me`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** get the user profile + * + * @param init - Options including `connect.sid` (session ID) and other configuration + * @returns A {@linkcode Response} object containing the user profile data + */ +export const get = ( + init?: BaseOptions, +): Promise< + ResponseOfEndpoint<{ 200: MemberUser | GuestUser }, R> +> => + setDefaults(init ?? {}).fetch( + makeGetRequest(init), + ) as Promise>; diff --git a/deno.jsonc b/deno.jsonc index ddc2399..4a2e042 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -24,7 +24,9 @@ "./unstable-api": "./api.ts", "./unstable-api/pages": "./api/pages.ts", "./unstable-api/pages/project": "./api/pages/project.ts", - "./unstable-api/pages/project/title": "./api/pages/project/title.ts" + "./unstable-api/pages/project/title": "./api/pages/project/title.ts", + "./unstable-api/users": "./api/users.ts", + "./unstable-api/users/me": "./api/users/me.ts" }, "imports": { "@core/unknownutil": "jsr:@core/unknownutil@^4.0.0", From 9efcc4a091b3c03b0706cf71e5371b827582fb13 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Wed, 19 Mar 2025 08:59:14 +0900 Subject: [PATCH 06/16] feat(api): Implement the `/api/pages/:project/:title/text` endpoint --- api/pages/project/title.ts | 2 + api/pages/project/title/text.ts | 68 +++++++++++++++++++++++++++++++++ deno.jsonc | 1 + 3 files changed, 71 insertions(+) create mode 100644 api/pages/project/title/text.ts diff --git a/api/pages/project/title.ts b/api/pages/project/title.ts index b082981..0b74037 100644 --- a/api/pages/project/title.ts +++ b/api/pages/project/title.ts @@ -80,3 +80,5 @@ export const get = ( 403: NotMemberError; }, R> >; + +export * as text from "./title/text.ts"; diff --git a/api/pages/project/title/text.ts b/api/pages/project/title/text.ts new file mode 100644 index 0000000..42de5f5 --- /dev/null +++ b/api/pages/project/title/text.ts @@ -0,0 +1,68 @@ +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, +} from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; +import { type BaseOptions, setDefaults } from "../../../../util.ts"; +import { encodeTitleURI } from "../../../../title.ts"; +import { cookie } from "../../../../rest/auth.ts"; + +/** Options for {@linkcode get} */ +export interface GetTextOption + extends BaseOptions { + /** use `followRename` */ + followRename?: boolean; +} + +/** Constructs a request for the `/api/pages/:project/:title/text` endpoint + * + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching page data + */ +export const makeGetRequest = ( + project: string, + title: string, + options?: GetTextOption, +): Request => { + const { sid, hostName, followRename } = setDefaults(options ?? {}); + + return new Request( + `https://${hostName}/api/pages/${project}/${ + encodeTitleURI(title) + }/text?followRename=${followRename ?? true}`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** Retrieves a specified page text + * + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options Additional configuration options for the request + * @returns A {@linkcode Response} object containing the page text + */ +export const get = ( + project: string, + title: string, + options?: GetTextOption, +): Promise< + ResponseOfEndpoint<{ + 200: string; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> +> => + setDefaults(options ?? {}).fetch( + makeGetRequest(project, title, options), + ) as Promise< + ResponseOfEndpoint<{ + 200: string; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> + >; diff --git a/deno.jsonc b/deno.jsonc index 4a2e042..d46b4f3 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -25,6 +25,7 @@ "./unstable-api/pages": "./api/pages.ts", "./unstable-api/pages/project": "./api/pages/project.ts", "./unstable-api/pages/project/title": "./api/pages/project/title.ts", + "./unstable-api/pages/project/title/text": "./api/pages/project/title/text.ts", "./unstable-api/users": "./api/users.ts", "./unstable-api/users/me": "./api/users/me.ts" }, From ecc74fd59d6cf83acbf2bca77a9814ee207c18bc Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:01:21 +0900 Subject: [PATCH 07/16] feat(api): Implement the `/api/pages/:project/:title/icon` endpoint --- api/pages/project/title.ts | 1 + api/pages/project/title/icon.ts | 68 +++++++++++++++++++++++++++++++++ deno.jsonc | 1 + 3 files changed, 70 insertions(+) create mode 100644 api/pages/project/title/icon.ts diff --git a/api/pages/project/title.ts b/api/pages/project/title.ts index 0b74037..0b84554 100644 --- a/api/pages/project/title.ts +++ b/api/pages/project/title.ts @@ -82,3 +82,4 @@ export const get = ( >; export * as text from "./title/text.ts"; +export * as icon from "./title/icon.ts"; diff --git a/api/pages/project/title/icon.ts b/api/pages/project/title/icon.ts new file mode 100644 index 0000000..e29075a --- /dev/null +++ b/api/pages/project/title/icon.ts @@ -0,0 +1,68 @@ +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, +} from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; +import { type BaseOptions, setDefaults } from "../../../../util.ts"; +import { encodeTitleURI } from "../../../../title.ts"; +import { cookie } from "../../../../rest/auth.ts"; + +/** Options for {@linkcode get} */ +export interface GetIconOption + extends BaseOptions { + /** use `followRename` */ + followRename?: boolean; +} + +/** Constructs a request for the `/api/pages/:project/:title/icon` endpoint + * + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching page data + */ +export const makeGetRequest = ( + project: string, + title: string, + options?: GetIconOption, +): Request => { + const { sid, hostName, followRename } = setDefaults(options ?? {}); + + return new Request( + `https://${hostName}/api/pages/${project}/${ + encodeTitleURI(title) + }/icon?followRename=${followRename ?? true}`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** Retrieves a specified page image + * + * @param project The project name containing the desired page + * @param title The page title to retrieve (case insensitive) + * @param options Additional configuration options for the request + * @returns A {@linkcode Response} object containing the page image + */ +export const get = ( + project: string, + title: string, + options?: GetIconOption, +): Promise< + ResponseOfEndpoint<{ + 200: Blob; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> +> => + setDefaults(options ?? {}).fetch( + makeGetRequest(project, title, options), + ) as Promise< + ResponseOfEndpoint<{ + 200: Blob; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> + >; diff --git a/deno.jsonc b/deno.jsonc index d46b4f3..c69bfd5 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -26,6 +26,7 @@ "./unstable-api/pages/project": "./api/pages/project.ts", "./unstable-api/pages/project/title": "./api/pages/project/title.ts", "./unstable-api/pages/project/title/text": "./api/pages/project/title/text.ts", + "./unstable-api/pages/project/title/icon": "./api/pages/project/title/icon.ts", "./unstable-api/users": "./api/users.ts", "./unstable-api/users/me": "./api/users/me.ts" }, From 8140a9673c7ff7edaaa440bb00cd0226301388d4 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:18:50 +0900 Subject: [PATCH 08/16] feat(api): Implement the `/api/pages/:project/replace/links` endpoint --- api/pages/project.ts | 1 + api/pages/project/replace.ts | 1 + api/pages/project/replace/links.ts | 85 ++++++++++++++++++++++++++++++ deno.jsonc | 2 + util.ts | 14 +++++ 5 files changed, 103 insertions(+) create mode 100644 api/pages/project/replace.ts create mode 100644 api/pages/project/replace/links.ts diff --git a/api/pages/project.ts b/api/pages/project.ts index 952f51f..c3a4077 100644 --- a/api/pages/project.ts +++ b/api/pages/project.ts @@ -136,3 +136,4 @@ export async function* list( } export * as title from "./project/title.ts"; +export * as replace from "./project/replace.ts"; diff --git a/api/pages/project/replace.ts b/api/pages/project/replace.ts new file mode 100644 index 0000000..b811f7e --- /dev/null +++ b/api/pages/project/replace.ts @@ -0,0 +1 @@ +export * as links from "./replace/links.ts"; diff --git a/api/pages/project/replace/links.ts b/api/pages/project/replace/links.ts new file mode 100644 index 0000000..8a78103 --- /dev/null +++ b/api/pages/project/replace/links.ts @@ -0,0 +1,85 @@ +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, +} from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; +import { type ExtendedOptions, setDefaults } from "../../../../util.ts"; +import { cookie } from "../../../../rest/auth.ts"; +import { get } from "../../../users/me.ts"; + +/** Constructs a request for the `/api/pages/:project/replace/links` endpoint + * + * @param project - The project name where all links will be replaced + * @param from - The original link text to be replaced + * @param to - The new link text to replace with + * @param init - Additional configuration options + * @returns A {@linkcode Request} object for replacing links in `project` + */ +export const makePostRequest = ( + project: string, + from: string, + to: string, + init?: ExtendedOptions, +): Request => { + const { sid, hostName, csrf } = setDefaults(init ?? {}); + + return new Request( + `https://${hostName}/api/pages/${project}/replace/links`, + { + method: "POST", + headers: { + "Content-Type": "application/json;charset=utf-8", + "X-CSRF-TOKEN": csrf ?? "", + ...(sid ? { Cookie: cookie(sid) } : {}), + }, + body: JSON.stringify({ from, to }), + }, + ); +}; + +/** Retrieves JSON data for a specified page + * + * @param project - The project name where all links will be replaced + * @param from - The original link text to be replaced + * @param to - The new link text to replace with + * @param init - Additional configuration options + * @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing: + * - Success: The page data in JSON format + * - Error: One of several possible errors: + * - {@linkcode NotFoundError}: Page not found + * - {@linkcode NotLoggedInError}: Authentication required + * - {@linkcode NotMemberError}: User lacks access + */ +export const post = async ( + project: string, + from: string, + to: string, + init?: ExtendedOptions, +): Promise< + ResponseOfEndpoint<{ + 200: string; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> +> => { + let { csrf, fetch, ...init2 } = setDefaults(init ?? {}); + + if (!csrf) { + const res = await get(init2); + if (!res.ok) return res; + csrf = (await res.json()).csrfToken; + } + + return fetch( + makePostRequest(project, from, to, { csrf, ...init2 }), + ) as Promise< + ResponseOfEndpoint<{ + 200: string; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> + >; +}; diff --git a/deno.jsonc b/deno.jsonc index c69bfd5..4ec8284 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -27,6 +27,8 @@ "./unstable-api/pages/project/title": "./api/pages/project/title.ts", "./unstable-api/pages/project/title/text": "./api/pages/project/title/text.ts", "./unstable-api/pages/project/title/icon": "./api/pages/project/title/icon.ts", + "./unstable-api/pages/project/replace": "./api/pages/project/replace.ts", + "./unstable-api/pages/project/replace/links": "./api/pages/project/replace/links.ts", "./unstable-api/users": "./api/users.ts", "./unstable-api/users/me": "./api/users/me.ts" }, diff --git a/util.ts b/util.ts index 346a56c..870c1b3 100644 --- a/util.ts +++ b/util.ts @@ -54,6 +54,20 @@ export interface OAuthOptions accessToken: string; } +/** Extended options including CSRF token configuration + * + * Extends BaseOptions with CSRF token support for endpoints + * that require CSRF protection. + */ +export interface ExtendedOptions + extends BaseOptions { + /** CSRF token + * + * If it isn't set, automatically get CSRF token from scrapbox.io server. + */ + csrf?: string; +} + /** Set default values for {@linkcode BaseOptions} * * Ensures all required fields have appropriate default values while From 05c5c9afad32dba11c58d547efa6002d347cf5d4 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Thu, 20 Mar 2025 08:27:56 +0900 Subject: [PATCH 09/16] feat(api): Implement the `/api/pages/:project/search/query` endpoint --- api/pages/project.ts | 3 +- api/pages/project/search.ts | 1 + api/pages/project/search/query.ts | 73 +++++++++++++++++++++++++++++++ deno.jsonc | 6 ++- 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 api/pages/project/search.ts create mode 100644 api/pages/project/search/query.ts diff --git a/api/pages/project.ts b/api/pages/project.ts index c3a4077..db97e33 100644 --- a/api/pages/project.ts +++ b/api/pages/project.ts @@ -135,5 +135,6 @@ export async function* list( } } -export * as title from "./project/title.ts"; export * as replace from "./project/replace.ts"; +export * as search from "./project/search.ts"; +export * as title from "./project/title.ts"; diff --git a/api/pages/project/search.ts b/api/pages/project/search.ts new file mode 100644 index 0000000..a84826e --- /dev/null +++ b/api/pages/project/search.ts @@ -0,0 +1 @@ +export * as query from "./search/query.ts"; diff --git a/api/pages/project/search/query.ts b/api/pages/project/search/query.ts new file mode 100644 index 0000000..f6ef611 --- /dev/null +++ b/api/pages/project/search/query.ts @@ -0,0 +1,73 @@ +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, + SearchResult, +} from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; +import { type BaseOptions, setDefaults } from "../../../../util.ts"; +import { cookie } from "../../../../rest/auth.ts"; + +/** Options for {@linkcode getPage} */ +export interface GetPageOption + extends BaseOptions { + /** use `followRename` */ + followRename?: boolean; + + /** project ids to get External links */ + projects?: string[]; +} + +/** Constructs a request for the `/api/pages/:project/search/query` endpoint + * + * @param project The name of the project to search within + * @param query The search query string to match against pages + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching page data + */ +export const makeGetRequest = ( + project: string, + query: string, + options?: BaseOptions, +): Request => { + const { sid, hostName } = setDefaults(options ?? {}); + + return new Request( + `https://${hostName}/api/pages/${project}/search/query?q=${ + encodeURIComponent(query) + }`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** Search for pages within a specific project + * + * @param project The name of the project to search within + * @param query The search query string to match against pages + * @param options Additional configuration options for the request + * @returns A {@linkcode Response} object containing the search results + */ +export const get = ( + project: string, + query: string, + options?: BaseOptions, +): Promise< + ResponseOfEndpoint<{ + 200: SearchResult; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + 422: { message: string }; + }, R> +> => + setDefaults(options ?? {}).fetch( + makeGetRequest(project, query, options), + ) as Promise< + ResponseOfEndpoint<{ + 200: SearchResult; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + 422: { message: string }; + }, R> + >; diff --git a/deno.jsonc b/deno.jsonc index 4ec8284..a963484 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -24,11 +24,13 @@ "./unstable-api": "./api.ts", "./unstable-api/pages": "./api/pages.ts", "./unstable-api/pages/project": "./api/pages/project.ts", + "./unstable-api/pages/project/replace": "./api/pages/project/replace.ts", + "./unstable-api/pages/project/replace/links": "./api/pages/project/replace/links.ts", + "./unstable-api/pages/project/search": "./api/pages/project/search.ts", + "./unstable-api/pages/project/search/query": "./api/pages/project/search/query.ts", "./unstable-api/pages/project/title": "./api/pages/project/title.ts", "./unstable-api/pages/project/title/text": "./api/pages/project/title/text.ts", "./unstable-api/pages/project/title/icon": "./api/pages/project/title/icon.ts", - "./unstable-api/pages/project/replace": "./api/pages/project/replace.ts", - "./unstable-api/pages/project/replace/links": "./api/pages/project/replace/links.ts", "./unstable-api/users": "./api/users.ts", "./unstable-api/users/me": "./api/users/me.ts" }, From a59b747b4771dfcd930094da8f9c7f9088c7392f Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Thu, 20 Mar 2025 08:38:46 +0900 Subject: [PATCH 10/16] fix(api): Remove an unused interface --- api/pages/project/search/query.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/api/pages/project/search/query.ts b/api/pages/project/search/query.ts index f6ef611..cf3258e 100644 --- a/api/pages/project/search/query.ts +++ b/api/pages/project/search/query.ts @@ -8,16 +8,6 @@ import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; import { type BaseOptions, setDefaults } from "../../../../util.ts"; import { cookie } from "../../../../rest/auth.ts"; -/** Options for {@linkcode getPage} */ -export interface GetPageOption - extends BaseOptions { - /** use `followRename` */ - followRename?: boolean; - - /** project ids to get External links */ - projects?: string[]; -} - /** Constructs a request for the `/api/pages/:project/search/query` endpoint * * @param project The name of the project to search within From 26e90d8593fdfeb92152ab051e78009a83d37f38 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Thu, 20 Mar 2025 08:54:18 +0900 Subject: [PATCH 11/16] feat(api): Implement the `/api/pages/:project/search/titles` endpoint --- api/pages/project/search.ts | 1 + api/pages/project/search/titles.ts | 117 +++++++++++++++++++++++++++++ deno.jsonc | 1 + 3 files changed, 119 insertions(+) create mode 100644 api/pages/project/search/titles.ts diff --git a/api/pages/project/search.ts b/api/pages/project/search.ts index a84826e..0d80f9d 100644 --- a/api/pages/project/search.ts +++ b/api/pages/project/search.ts @@ -1 +1,2 @@ export * as query from "./search/query.ts"; +export * as titles from "./search/titles.ts"; diff --git a/api/pages/project/search/titles.ts b/api/pages/project/search/titles.ts new file mode 100644 index 0000000..79586f3 --- /dev/null +++ b/api/pages/project/search/titles.ts @@ -0,0 +1,117 @@ +import type { + NotFoundError, + NotLoggedInError, + NotMemberError, + SearchedTitle, +} from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../../../targeted_response.ts"; +import { type BaseOptions, setDefaults } from "../../../../util.ts"; +import { cookie } from "../../../../rest/auth.ts"; +import { + type HTTPError, + makeError, + makeHTTPError, + type TypedError, +} from "../../../../error.ts"; + +/** Options for {@linkcode get} */ +export interface GetLinksOptions + extends BaseOptions { + /** ID indicating the next list of links */ + followingId?: string; +} + +/** Create a request to `GET /api/pages/:project/search/titles` + * + * @param project The project to get the links from + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching link data + */ +export const makeGetRequest = ( + project: string, + options?: GetLinksOptions, +): Request => { + const { sid, hostName, followingId } = setDefaults(options ?? {}); + + return new Request( + `https://${hostName}/api/pages/${project}/search/titles${ + followingId ? `?followingId=${followingId}` : "" + }`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** Retrieve link data from a specified Scrapbox project + * + * This function fetches link data from a project, supporting pagination through + * the {@linkcode GetLinksOptions.followingId} parameter. It returns both the link data and the next + * followingId for subsequent requests. + * + * @param project The project to retrieve link data from + * @param options Additional configuration options for the request + * @returns A {@linkcode Response} object containing the link data + */ +export const get = ( + project: string, + options?: GetLinksOptions, +): Promise< + ResponseOfEndpoint<{ + 200: SearchedTitle[]; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + 422: { message: string }; + }, R> +> => + setDefaults(options ?? {}).fetch( + makeGetRequest(project, options), + ) as Promise< + ResponseOfEndpoint<{ + 200: SearchedTitle[]; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + 422: { message: string }; + }, R> + >; + +/** Retrieve all link data from a specified project one by one + * + * @param project The project name to list pages from + * @param options Additional configuration options for the request + * @returns An async generator that yields each link data + * @throws {TypedError<"NotLoggedInError" | "NotMemberError" | "NotFoundError" | "InvalidFollowingIdError"> | HTTPError} + */ +export async function* list( + project: string, + options?: GetLinksOptions, +): AsyncGenerator { + let followingId = options?.followingId ?? ""; + do { + const response = await get(project, { ...options, followingId }); + switch (response.status) { + case 200: + break; + case 401: + case 403: + case 404: { + const error = await response.json(); + throw makeError(error.name, error.message) satisfies TypedError< + "NotLoggedInError" | "NotMemberError" | "NotFoundError" + >; + } + case 422: + throw makeError( + "InvalidFollowingIdError", + (await response.json()).message, + ) satisfies TypedError< + "InvalidFollowingIdError" + >; + default: + throw makeHTTPError(response) satisfies HTTPError; + } + const titles = await response.json(); + yield* titles; + followingId = response.headers.get("X-following-id") ?? ""; + } while (followingId); +} diff --git a/deno.jsonc b/deno.jsonc index a963484..ba65b7b 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -28,6 +28,7 @@ "./unstable-api/pages/project/replace/links": "./api/pages/project/replace/links.ts", "./unstable-api/pages/project/search": "./api/pages/project/search.ts", "./unstable-api/pages/project/search/query": "./api/pages/project/search/query.ts", + "./unstable-api/pages/project/search/titles": "./api/pages/project/search/titles.ts", "./unstable-api/pages/project/title": "./api/pages/project/title.ts", "./unstable-api/pages/project/title/text": "./api/pages/project/title/text.ts", "./unstable-api/pages/project/title/icon": "./api/pages/project/title/icon.ts", From 98dae98048ee8bbd72e3e475b6d3f9e6f4773410 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:13:04 +0900 Subject: [PATCH 12/16] feat(api): Implement the `/api/projects/:project` endpoint --- api.ts | 1 + api/projects.ts | 1 + api/projects/project.ts | 60 +++++++++++++++++++++++++++++++++++++++++ deno.jsonc | 2 ++ 4 files changed, 64 insertions(+) create mode 100644 api/projects.ts create mode 100644 api/projects/project.ts diff --git a/api.ts b/api.ts index 52456b7..1b58c40 100644 --- a/api.ts +++ b/api.ts @@ -1,2 +1,3 @@ export * as pages from "./api/pages.ts"; +export * as projects from "./api/projects.ts"; export * as users from "./api/users.ts"; diff --git a/api/projects.ts b/api/projects.ts new file mode 100644 index 0000000..36f503d --- /dev/null +++ b/api/projects.ts @@ -0,0 +1 @@ +export * as project from "./projects/project.ts"; diff --git a/api/projects/project.ts b/api/projects/project.ts new file mode 100644 index 0000000..2eda3b7 --- /dev/null +++ b/api/projects/project.ts @@ -0,0 +1,60 @@ +import type { + MemberProject, + NotFoundError, + NotLoggedInError, + NotMemberError, + NotMemberProject, +} from "@cosense/types/rest"; +import type { ResponseOfEndpoint } from "../../targeted_response.ts"; +import { type BaseOptions, setDefaults } from "../../util.ts"; +import { cookie } from "../../rest/auth.ts"; + +/** Create a request to `GET /api/projects/:project` + * + * @param project - Project name to retrieve information for + * @param options - Additional configuration options + * @returns A {@linkcode Request} object for fetching project data + */ +export const makeGetRequest = ( + project: string, + options?: BaseOptions, +): Request => { + const { sid, hostName } = setDefaults(options ?? {}); + + return new Request( + `https://${hostName}/api/projects/${project}`, + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); +}; + +/** Get detailed information about a Scrapbox project + * + * This function retrieves detailed information about a project, including its + * access level, settings, and metadata. The returned data type depends on + * whether the user has member access to the project. + * + * @param project - Project name to retrieve information for + * @param options Additional configuration options for the request + * @returns A {@linkcode Response} object containing the project data + */ +export const get = ( + project: string, + options?: BaseOptions, +): Promise< + ResponseOfEndpoint<{ + 200: MemberProject | NotMemberProject; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> +> => + setDefaults(options ?? {}).fetch( + makeGetRequest(project, options), + ) as Promise< + ResponseOfEndpoint<{ + 200: MemberProject | NotMemberProject; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, R> + >; diff --git a/deno.jsonc b/deno.jsonc index ba65b7b..91e9b4e 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -30,6 +30,8 @@ "./unstable-api/pages/project/search/query": "./api/pages/project/search/query.ts", "./unstable-api/pages/project/search/titles": "./api/pages/project/search/titles.ts", "./unstable-api/pages/project/title": "./api/pages/project/title.ts", + "./unstable-api/pages/projects": "./api/projects.ts", + "./unstable-api/pages/projects/project": "./api/projects/project.ts", "./unstable-api/pages/project/title/text": "./api/pages/project/title/text.ts", "./unstable-api/pages/project/title/icon": "./api/pages/project/title/icon.ts", "./unstable-api/users": "./api/users.ts", From b53e29abb152a9ed39d5ea68c76588adccf53e1c Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:26:35 +0900 Subject: [PATCH 13/16] feat(api): Use `baseURL` instead of `hostName` following the style of `jsr:@openai/openai` --- api/pages/project.ts | 4 ++-- api/pages/project/replace/links.ts | 4 ++-- api/pages/project/search/query.ts | 4 ++-- api/pages/project/search/titles.ts | 4 ++-- api/pages/project/title.ts | 6 ++---- api/pages/project/title/icon.ts | 4 ++-- api/pages/project/title/text.ts | 4 ++-- api/projects/project.ts | 4 ++-- api/users/me.ts | 4 ++-- util.ts | 21 ++++++++++----------- 10 files changed, 28 insertions(+), 31 deletions(-) diff --git a/api/pages/project.ts b/api/pages/project.ts index db97e33..5537df6 100644 --- a/api/pages/project.ts +++ b/api/pages/project.ts @@ -53,7 +53,7 @@ export const makeGetRequest = ( project: string, options?: ListPagesOption, ): Request => { - const { sid, hostName, sort, limit, skip } = setDefaults( + const { sid, baseURL, sort, limit, skip } = setDefaults( options ?? {}, ); const params = new URLSearchParams(); @@ -62,7 +62,7 @@ export const makeGetRequest = ( if (skip !== undefined) params.append("skip", `${skip}`); return new Request( - `https://${hostName}/api/pages/${project}?${params}`, + `${baseURL}api/pages/${project}?${params}`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); }; diff --git a/api/pages/project/replace/links.ts b/api/pages/project/replace/links.ts index 8a78103..4051f57 100644 --- a/api/pages/project/replace/links.ts +++ b/api/pages/project/replace/links.ts @@ -22,10 +22,10 @@ export const makePostRequest = ( to: string, init?: ExtendedOptions, ): Request => { - const { sid, hostName, csrf } = setDefaults(init ?? {}); + const { sid, baseURL, csrf } = setDefaults(init ?? {}); return new Request( - `https://${hostName}/api/pages/${project}/replace/links`, + `${baseURL}api/pages/${project}/replace/links`, { method: "POST", headers: { diff --git a/api/pages/project/search/query.ts b/api/pages/project/search/query.ts index cf3258e..aa3fd82 100644 --- a/api/pages/project/search/query.ts +++ b/api/pages/project/search/query.ts @@ -20,10 +20,10 @@ export const makeGetRequest = ( query: string, options?: BaseOptions, ): Request => { - const { sid, hostName } = setDefaults(options ?? {}); + const { sid, baseURL } = setDefaults(options ?? {}); return new Request( - `https://${hostName}/api/pages/${project}/search/query?q=${ + `${baseURL}api/pages/${project}/search/query?q=${ encodeURIComponent(query) }`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, diff --git a/api/pages/project/search/titles.ts b/api/pages/project/search/titles.ts index 79586f3..b767e55 100644 --- a/api/pages/project/search/titles.ts +++ b/api/pages/project/search/titles.ts @@ -31,10 +31,10 @@ export const makeGetRequest = ( project: string, options?: GetLinksOptions, ): Request => { - const { sid, hostName, followingId } = setDefaults(options ?? {}); + const { sid, baseURL, followingId } = setDefaults(options ?? {}); return new Request( - `https://${hostName}/api/pages/${project}/search/titles${ + `${baseURL}api/pages/${project}/search/titles${ followingId ? `?followingId=${followingId}` : "" }`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, diff --git a/api/pages/project/title.ts b/api/pages/project/title.ts index 0b84554..87af2ff 100644 --- a/api/pages/project/title.ts +++ b/api/pages/project/title.ts @@ -31,7 +31,7 @@ export const makeGetRequest = ( title: string, options?: GetPageOption, ): Request => { - const { sid, hostName, followRename, projects } = setDefaults(options ?? {}); + const { sid, baseURL, followRename, projects } = setDefaults(options ?? {}); const params = new URLSearchParams([ ["followRename", `${followRename ?? true}`], @@ -39,9 +39,7 @@ export const makeGetRequest = ( ]); return new Request( - `https://${hostName}/api/pages/${project}/${ - encodeTitleURI(title) - }?${params}`, + `${baseURL}api/pages/${project}/${encodeTitleURI(title)}?${params}`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); }; diff --git a/api/pages/project/title/icon.ts b/api/pages/project/title/icon.ts index e29075a..9e6ac36 100644 --- a/api/pages/project/title/icon.ts +++ b/api/pages/project/title/icon.ts @@ -27,10 +27,10 @@ export const makeGetRequest = ( title: string, options?: GetIconOption, ): Request => { - const { sid, hostName, followRename } = setDefaults(options ?? {}); + const { sid, baseURL, followRename } = setDefaults(options ?? {}); return new Request( - `https://${hostName}/api/pages/${project}/${ + `${baseURL}api/pages/${project}/${ encodeTitleURI(title) }/icon?followRename=${followRename ?? true}`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, diff --git a/api/pages/project/title/text.ts b/api/pages/project/title/text.ts index 42de5f5..10cd245 100644 --- a/api/pages/project/title/text.ts +++ b/api/pages/project/title/text.ts @@ -27,10 +27,10 @@ export const makeGetRequest = ( title: string, options?: GetTextOption, ): Request => { - const { sid, hostName, followRename } = setDefaults(options ?? {}); + const { sid, baseURL, followRename } = setDefaults(options ?? {}); return new Request( - `https://${hostName}/api/pages/${project}/${ + `${baseURL}api/pages/${project}/${ encodeTitleURI(title) }/text?followRename=${followRename ?? true}`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, diff --git a/api/projects/project.ts b/api/projects/project.ts index 2eda3b7..e036961 100644 --- a/api/projects/project.ts +++ b/api/projects/project.ts @@ -19,10 +19,10 @@ export const makeGetRequest = ( project: string, options?: BaseOptions, ): Request => { - const { sid, hostName } = setDefaults(options ?? {}); + const { sid, baseURL } = setDefaults(options ?? {}); return new Request( - `https://${hostName}/api/projects/${project}`, + `${baseURL}api/projects/${project}`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); }; diff --git a/api/users/me.ts b/api/users/me.ts index 162f672..158167c 100644 --- a/api/users/me.ts +++ b/api/users/me.ts @@ -14,9 +14,9 @@ import { cookie } from "../../rest/auth.ts"; export const makeGetRequest = ( init?: BaseOptions, ): Request => { - const { sid, hostName } = setDefaults(init ?? {}); + const { sid, baseURL } = setDefaults(init ?? {}); return new Request( - `https://${hostName}/api/users/me`, + `${baseURL}api/users/me`, sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); }; diff --git a/util.ts b/util.ts index 870c1b3..bca4c32 100644 --- a/util.ts +++ b/util.ts @@ -36,15 +36,11 @@ export interface BaseOptions { */ fetch?: Fetch; - /** Domain for REST API endpoints + /** Base URL for REST API endpoints * - * Configurable host name for API requests. This allows using the library - * with self-hosted Scrapbox instances or other custom deployments that - * don't use the default scrapbox.io domain. - * - * @default {"scrapbox.io"} + * @default {"https://scrapbox.io/"} */ - hostName?: string; + baseURL?: string; } /** Options for Gyazo API which requires OAuth */ @@ -83,8 +79,11 @@ export const setDefaults = < T extends BaseOptions = BaseOptions, >( options: T, -): Omit & Required> => { - const { fetch = globalThis.fetch, hostName = "scrapbox.io", ...rest } = - options; - return { fetch, hostName, ...rest }; +): Omit & Required> => { + const { + fetch = globalThis.fetch, + baseURL = "https://scrapbox.io/", + ...rest + } = options; + return { fetch, baseURL, ...rest }; }; From ec34c846b89fe62abb4a817d7551e6c3cd252682 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:51:52 +0900 Subject: [PATCH 14/16] feat(api): Make pagination in `list` function concurrent and add `poolLimit` option --- api/pages/project.ts | 92 ++++++++++++++++++++++++++++++++------------ deno.jsonc | 3 +- deno.lock | 13 +++++-- 3 files changed, 79 insertions(+), 29 deletions(-) diff --git a/api/pages/project.ts b/api/pages/project.ts index 5537df6..1dd40b7 100644 --- a/api/pages/project.ts +++ b/api/pages/project.ts @@ -7,15 +7,21 @@ import type { } from "@cosense/types/rest"; import { type BaseOptions, setDefaults } from "../../util.ts"; import { cookie } from "../../rest/auth.ts"; -import type { ResponseOfEndpoint } from "../../targeted_response.ts"; +import type { + ResponseOfEndpoint, + TargetedResponse, +} from "../../targeted_response.ts"; import { type HTTPError, makeError, makeHTTPError, type TypedError, } from "../../error.ts"; +import { pooledMap } from "@std/async/pool"; +import { range } from "@core/iterutil/range"; +import { flatten } from "@core/iterutil/async/flatten"; -/** Options for {@linkcode listPages} */ +/** Options for {@linkcode get} */ export interface ListPagesOption extends BaseOptions { /** the sort of page list to return @@ -100,6 +106,16 @@ export const get = ( }, R> >; +/** Options for {@linkcode list} */ +export interface ListPagesStreamOption + extends ListPagesOption { + /** The number of requests to make concurrently + * + * @default {3} + */ + poolLimit?: number; +} + /** * Lists pages from a given `project` with pagination * @@ -109,31 +125,59 @@ export const get = ( */ export async function* list( project: string, - options?: ListPagesOption, + options?: ListPagesStreamOption, ): AsyncGenerator { - const props = { ...(options ?? {}), skip: options?.skip ?? 0 }; - while (true) { - const response = await get(project, props); - switch (response.status) { - case 200: - break; - case 401: - case 403: - case 404: { - const error = await response.json(); - throw makeError(error.name, error.message) satisfies TypedError< - "NotLoggedInError" | "NotMemberError" | "NotFoundError" - >; - } - default: - throw makeHTTPError(response) satisfies HTTPError; + const props = { + ...(options ?? {}), + skip: options?.skip ?? 0, + limit: options?.limit ?? 100, + }; + const response = await ensureResponse(await get(project, props)); + const list = await response.json(); + yield* list.pages; + + const limit = list.limit; + const skip = list.skip + limit; + const times = Math.ceil((list.count - skip) / limit); + + yield* flatten( + pooledMap( + options?.poolLimit ?? 3, + range(0, times - 1), + async (i) => { + const response = await ensureResponse( + await get(project, { ...props, skip: skip + i * limit, limit }), + ); + const list = await response.json(); + return list.pages; + }, + ), + ); +} + +const ensureResponse = async ( + response: ResponseOfEndpoint<{ + 200: PageList; + 404: NotFoundError; + 401: NotLoggedInError; + 403: NotMemberError; + }, Response>, +): Promise> => { + switch (response.status) { + case 200: + return response; + case 401: + case 403: + case 404: { + const error = await response.json(); + throw makeError(error.name, error.message) satisfies TypedError< + "NotLoggedInError" | "NotMemberError" | "NotFoundError" + >; } - const list = await response.json(); - yield* list.pages; - props.skip += props.limit ?? 100; - if (list.skip + list.limit >= list.count) break; + default: + throw makeHTTPError(response) satisfies HTTPError; } -} +}; export * as replace from "./project/replace.ts"; export * as search from "./project/search.ts"; diff --git a/deno.jsonc b/deno.jsonc index 91e9b4e..4ec10d1 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -38,6 +38,7 @@ "./unstable-api/users/me": "./api/users/me.ts" }, "imports": { + "@core/iterutil": "jsr:@core/iterutil@^0.9.0", "@core/unknownutil": "jsr:@core/unknownutil@^4.0.0", "@cosense/std/browser/websocket": "./websocket/mod.ts", "@cosense/std/rest": "./rest/mod.ts", @@ -47,7 +48,7 @@ "@cosense/types/userscript": "jsr:@cosense/types@0.10/userscript", "@progfay/scrapbox-parser": "jsr:@progfay/scrapbox-parser@9", "@std/assert": "jsr:@std/assert@1", - "@std/async": "jsr:@std/async@1", + "@std/async": "jsr:@std/async@^1.0.11", "@std/encoding": "jsr:@std/encoding@1", "@std/http": "jsr:@std/http@^1.0.13", "@std/json": "jsr:@std/json@^1.0.0", diff --git a/deno.lock b/deno.lock index fe068a5..5c662df 100644 --- a/deno.lock +++ b/deno.lock @@ -1,13 +1,14 @@ { "version": "4", "specifiers": { + "jsr:@core/iterutil@0.9": "0.9.0", "jsr:@core/unknownutil@4": "4.3.0", "jsr:@cosense/types@0.10": "0.10.7", "jsr:@cosense/types@~0.10.7": "0.10.7", "jsr:@progfay/scrapbox-parser@9": "9.2.0", "jsr:@std/assert@1": "1.0.11", "jsr:@std/assert@^1.0.10": "1.0.11", - "jsr:@std/async@1": "1.0.10", + "jsr:@std/async@^1.0.11": "1.0.11", "jsr:@std/cli@^1.0.12": "1.0.13", "jsr:@std/data-structures@^1.0.6": "1.0.6", "jsr:@std/encoding@1": "1.0.7", @@ -30,6 +31,9 @@ "npm:socket.io-client@^4.7.5": "4.8.1" }, "jsr": { + "@core/iterutil@0.9.0": { + "integrity": "29a4ba1af8e79c2d63d96df4948bb995afb5256568401711ae97a1ef06cc67a8" + }, "@core/unknownutil@4.3.0": { "integrity": "538a3687ffa81028e91d148818047df219663d0da671d906cecd165581ae55cc" }, @@ -45,8 +49,8 @@ "jsr:@std/internal" ] }, - "@std/async@1.0.10": { - "integrity": "2ff1b1c7d33d1416159989b0f69e59ec7ee8cb58510df01e454def2108b3dbec" + "@std/async@1.0.11": { + "integrity": "eee0d3405275506638a9c8efaa849cf0d35873120c69b7caa1309c9a9e5b6f85" }, "@std/cli@1.0.13": { "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" @@ -180,12 +184,13 @@ }, "workspace": { "dependencies": [ + "jsr:@core/iterutil@0.9", "jsr:@core/unknownutil@4", "jsr:@cosense/types@0.10", "jsr:@cosense/types@~0.10.7", "jsr:@progfay/scrapbox-parser@9", "jsr:@std/assert@1", - "jsr:@std/async@1", + "jsr:@std/async@^1.0.11", "jsr:@std/encoding@1", "jsr:@std/http@^1.0.13", "jsr:@std/json@1", From 1151b950d68c50ea12b5ef58fdd69c336192a163 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:04:39 +0900 Subject: [PATCH 15/16] refactor(api): Re-export REST APIs from `/unstable-api` directly, distinguishing each name. --- api.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/api.ts b/api.ts index 1b58c40..6bd720b 100644 --- a/api.ts +++ b/api.ts @@ -1,3 +1,48 @@ export * as pages from "./api/pages.ts"; export * as projects from "./api/projects.ts"; export * as users from "./api/users.ts"; + +export { + get as listPages, + list as listPagesStream, + type ListPagesOption, + type ListPagesStreamOption, + makeGetRequest as makeListPagesRequest, +} from "./api/pages/project.ts"; +export { + makePostRequest as makeReplaceLinksRequest, + post as replaceLinks, +} from "./api/pages/project/replace/links.ts"; +export { + get as searchForPages, + makeGetRequest as makeSearchForPagesRequest, +} from "./api/pages/project/search/query.ts"; +export { + get as getLinks, + type GetLinksOptions, + list as readLinks, + makeGetRequest as makeGetLinksRequest, +} from "./api/pages/project/search/titles.ts"; +export { + get as getPage, + type GetPageOption, + makeGetRequest as makeGetPageRequest, +} from "./api/pages/project/title.ts"; +export { + get as getText, + type GetTextOption, + makeGetRequest as makeGetTextRequest, +} from "./api/pages/project/title/text.ts"; +export { + get as getIcon, + type GetIconOption, + makeGetRequest as makeGetIconRequest, +} from "./api/pages/project/title/icon.ts"; +export { + get as getProject, + makeGetRequest as makeGetProjectRequest, +} from "./api/projects/project.ts"; +export { + get as getUser, + makeGetRequest as makeGetUserRequest, +} from "./api/users/me.ts"; From abccd645c3a617f095b503fdb382d81bd140e499 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:08:03 +0900 Subject: [PATCH 16/16] refactor(api): Export error types and options --- api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api.ts b/api.ts index 6bd720b..d3adbc3 100644 --- a/api.ts +++ b/api.ts @@ -1,6 +1,8 @@ export * as pages from "./api/pages.ts"; export * as projects from "./api/projects.ts"; export * as users from "./api/users.ts"; +export type { HTTPError, TypedError } from "./error.ts"; +export type { BaseOptions, ExtendedOptions, OAuthOptions } from "./util.ts"; export { get as listPages,