diff --git a/browser/websocket/pull.ts b/browser/websocket/pull.ts index 6af9f72..cbc91e8 100644 --- a/browser/websocket/pull.ts +++ b/browser/websocket/pull.ts @@ -1,17 +1,15 @@ -import { - createErr, - createOk, - isErr, - mapForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { + GuestUser, + MemberUser, NotFoundError, NotLoggedInError, NotMemberError, Page, } from "@cosense/types/rest"; +import { + createErrorResponse, + createSuccessResponse, +} from "../../rest/utils.ts"; import { getPage, type GetPageOption, @@ -19,9 +17,10 @@ import { } from "../../rest/pages.ts"; import { getProfile } from "../../rest/profile.ts"; import { getProject } from "../../rest/project.ts"; -import type { HTTPError } from "../../rest/responseIntoResult.ts"; +import type { HTTPError } from "../../rest/errors.ts"; import type { AbortError, NetworkError } from "../../rest/robustFetch.ts"; import type { BaseOptions } from "../../rest/options.ts"; +import type { TargetedResponse } from "../../rest/targeted_response.ts"; export interface PushMetadata extends Page { projectId: string; @@ -42,42 +41,70 @@ export const pull = async ( project: string, title: string, options?: GetPageOption, -): Promise> => { +): Promise< + TargetedResponse<200 | 400 | 404 | 408 | 500, PushMetadata | PullError> +> => { const [pageRes, userRes, projectRes] = await Promise.all([ getPage(project, title, options), getUserId(options), getProjectId(project, options), ]); - if (isErr(pageRes)) return pageRes; - if (isErr(userRes)) return userRes; - if (isErr(projectRes)) return projectRes; - return createOk({ - ...unwrapOk(pageRes), - projectId: unwrapOk(projectRes), - userId: unwrapOk(userRes), - }); + + if (!pageRes.ok || !userRes.ok || !projectRes.ok) { + const status = pageRes.ok + ? (userRes.ok ? projectRes.status : userRes.status) + : pageRes.status; + const errorStatus = status === 404 + ? 404 + : (status === 408 ? 408 : (status === 500 ? 500 : 400)); + return createErrorResponse(errorStatus, { + message: "Failed to fetch required data", + } as PullError); + } + + const page = await pageRes.json() as Page; + const userId = await userRes.clone().text(); + const projectId = await projectRes.clone().text(); + + const metadata: PushMetadata = { + ...page, + projectId, + userId, + }; + return createSuccessResponse(metadata); }; // TODO: 編集不可なページはStream購読だけ提供する /** cached user ID */ let userId: string | undefined; const getUserId = async (init?: BaseOptions): Promise< - Result< - string, - Omit | NetworkError | AbortError | HTTPError + TargetedResponse< + 200 | 400 | 404 | 408 | 500, + | string + | Omit + | NetworkError + | AbortError + | HTTPError > > => { - if (userId) return createOk(userId); + if (userId) { + return createSuccessResponse(userId); + } const result = await getProfile(init); - if (isErr(result)) return result; + if (!result.ok) { + return createErrorResponse(400, { + name: "NotLoggedInError", + message: "Failed to fetch profile", + }); + } - const user = unwrapOk(result); + const user = await result.json() as MemberUser | GuestUser; if ("id" in user) { userId = user.id; - return createOk(user.id); + return createSuccessResponse(user.id); } - return createErr({ + return createErrorResponse(400, { name: "NotLoggedInError", message: "This script cannot be used without login", }); @@ -89,8 +116,9 @@ export const getProjectId = async ( project: string, options?: BaseOptions, ): Promise< - Result< - string, + TargetedResponse< + 200 | 400 | 404 | 408 | 500, + | string | NotFoundError | NotLoggedInError | NotMemberError @@ -100,13 +128,21 @@ export const getProjectId = async ( > > => { const cachedId = projectMap.get(project); - if (cachedId) return createOk(cachedId); + if (cachedId) { + return createSuccessResponse(cachedId); + } + + const result = await getProject(project, options); + if (!result.ok) { + return createErrorResponse(404, { + name: "NotFoundError", + message: `Project ${project} not found`, + project, + }); + } - return mapForResult( - await getProject(project, options), - ({ id }) => { - projectMap.set(project, id); - return id; - }, - ); + const data = await result.json(); + const id = (data as { id: string }).id; + projectMap.set(project, id); + return createSuccessResponse(id); }; diff --git a/browser/websocket/push.ts b/browser/websocket/push.ts index 45d4938..32e1ee9 100644 --- a/browser/websocket/push.ts +++ b/browser/websocket/push.ts @@ -115,8 +115,11 @@ export const push = async ( } const socket = unwrapOk(result); const pullResult = await pull(project, title); - if (isErr(pullResult)) return pullResult; - let metadata = unwrapOk(pullResult); + if (!pullResult.ok) { + const error = await pullResult.json() as PushError; + return createErr(error); + } + let metadata = await pullResult.json() as PushMetadata; try { let attempts = 0; @@ -167,8 +170,11 @@ export const push = async ( if (name === "NotFastForwardError") { await delay(1000); const pullResult = await pull(project, title); - if (isErr(pullResult)) return pullResult; - metadata = unwrapOk(pullResult); + if (!pullResult.ok) { + const error = await pullResult.json() as PushError; + return createErr(error); + } + metadata = await pullResult.json() as PushMetadata; } reason = name; // go back to the diff loop diff --git a/deno.jsonc b/deno.jsonc index f539401..565ebc0 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -18,6 +18,7 @@ "@std/async": "jsr:@std/async@1", "@std/encoding": "jsr:@std/encoding@1", "@std/json": "jsr:@std/json@^1.0.0", + "@std/testing": "jsr:@std/testing@^1.0.8", "@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 a104694..8a6420d 100644 --- a/deno.lock +++ b/deno.lock @@ -2,67 +2,78 @@ "version": "4", "specifiers": { "jsr:@core/unknownutil@4": "4.3.0", - "jsr:@cosense/types@0.10": "0.10.1", - "jsr:@progfay/scrapbox-parser@9": "9.1.5", - "jsr:@std/assert@1": "1.0.7", - "jsr:@std/assert@^1.0.7": "1.0.7", - "jsr:@std/async@1": "1.0.8", - "jsr:@std/async@^1.0.8": "1.0.8", - "jsr:@std/bytes@^1.0.2": "1.0.2", - "jsr:@std/data-structures@^1.0.4": "1.0.4", - "jsr:@std/encoding@1": "1.0.5", - "jsr:@std/fs@^1.0.5": "1.0.5", + "jsr:@cosense/types@0.10": "0.10.4", + "jsr:@progfay/scrapbox-parser@9": "9.2.0", + "jsr:@std/assert@1": "1.0.10", + "jsr:@std/assert@^1.0.10": "1.0.10", + "jsr:@std/async@1": "1.0.9", + "jsr:@std/cli@^1.0.8": "1.0.9", + "jsr:@std/encoding@^1.0.5": "1.0.6", + "jsr:@std/fmt@^1.0.3": "1.0.3", + "jsr:@std/fs@^1.0.8": "1.0.8", + "jsr:@std/html@^1.0.3": "1.0.3", + "jsr:@std/http@*": "1.0.12", "jsr:@std/internal@^1.0.5": "1.0.5", "jsr:@std/json@1": "1.0.1", - "jsr:@std/path@^1.0.7": "1.0.8", + "jsr:@std/json@^1.0.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.7", - "jsr:@std/testing@1": "1.0.4", - "jsr:@takker/gyazo@*": "0.3.0", - "jsr:@takker/md5@0.1": "0.1.0", - "npm:option-t@*": "50.0.0", - "npm:option-t@50": "50.0.0", - "npm:option-t@^49.1.0": "49.3.0", + "jsr:@std/streams@^1.0.7": "1.0.8", + "jsr:@std/streams@^1.0.8": "1.0.8", + "jsr:@std/testing@1": "1.0.8", + "jsr:@std/testing@^1.0.8": "1.0.8", + "npm:option-t@50": "50.0.2", "npm:socket.io-client@^4.7.5": "4.8.1" }, "jsr": { "@core/unknownutil@4.3.0": { "integrity": "538a3687ffa81028e91d148818047df219663d0da671d906cecd165581ae55cc" }, - "@cosense/types@0.10.1": { - "integrity": "13d2488a02c7b0b035a265bc3299affbdab1ea5b607516379685965cd37b2058" + "@cosense/types@0.10.4": { + "integrity": "04423c152a525df848c067f9c6aa05409baadf9da15d8e4569e1bcedfa3c7624" }, - "@progfay/scrapbox-parser@9.1.5": { - "integrity": "729a086b6675dd4a216875757c918c6bbea329d6e35e410516a16bbd6c468369" + "@progfay/scrapbox-parser@9.2.0": { + "integrity": "82ebb95e72dd0ea44547fd48e2bcb479fd2275fc26c4515598f742e5aaf6e0e5" }, - "@std/assert@1.0.7": { - "integrity": "64ce9fac879e0b9f3042a89b3c3f8ccfc9c984391af19e2087513a79d73e28c3", + "@std/assert@1.0.10": { + "integrity": "59b5cbac5bd55459a19045d95cc7c2ff787b4f8527c0dd195078ff6f9481fbb3", "dependencies": [ "jsr:@std/internal" ] }, - "@std/async@1.0.6": { - "integrity": "6d262156dd35c4a72ee1a2f8679be40264f370cfb92e2e13d4eca2ae05e16f34" + "@std/async@1.0.9": { + "integrity": "c6472fd0623b3f3daae023cdf7ca5535e1b721dfbf376562c0c12b3fb4867f91" }, - "@std/async@1.0.7": { - "integrity": "f4fadc0124432e37cba11e8b3880164661a664de00a65118d976848f32f96290" + "@std/cli@1.0.9": { + "integrity": "557e5865af000efbf3f737dcfea5b8ab86453594f4a9cd8d08c9fa83d8e3f3bc" }, - "@std/async@1.0.8": { - "integrity": "c057c5211a0f1d12e7dcd111ab430091301b8d64b4250052a79d277383bc3ba7" + "@std/encoding@1.0.6": { + "integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069" }, - "@std/bytes@1.0.2": { - "integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57" + "@std/fmt@1.0.3": { + "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f" }, - "@std/data-structures@1.0.4": { - "integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0" + "@std/fs@1.0.8": { + "integrity": "161c721b6f9400b8100a851b6f4061431c538b204bb76c501d02c508995cffe0", + "dependencies": [ + "jsr:@std/path" + ] }, - "@std/encoding@1.0.5": { - "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" + "@std/html@1.0.3": { + "integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988" }, - "@std/fs@1.0.5": { - "integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e", + "@std/http@1.0.12": { + "integrity": "85246d8bfe9c8e2538518725b158bdc31f616e0869255f4a8d9e3de919cab2aa", "dependencies": [ - "jsr:@std/path@^1.0.7" + "jsr:@std/cli", + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path", + "jsr:@std/streams@^1.0.8" ] }, "@std/internal@1.0.5": { @@ -71,37 +82,29 @@ "@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.7": { - "integrity": "1a93917ca0c58c01b2bfb93647189229b1702677f169b6fb61ad6241cd2e499b", - "dependencies": [ - "jsr:@std/bytes" - ] + "@std/streams@1.0.8": { + "integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3" }, - "@std/testing@1.0.4": { - "integrity": "ca1368d720b183f572d40c469bb9faf09643ddd77b54f8b44d36ae6b94940576", + "@std/testing@1.0.8": { + "integrity": "ceef535808fb7568e91b0f8263599bd29b1c5603ffb0377227f00a8ca9fe42a2", "dependencies": [ - "jsr:@std/assert@^1.0.7", - "jsr:@std/async@^1.0.8", - "jsr:@std/data-structures", + "jsr:@std/assert@^1.0.10", "jsr:@std/fs", "jsr:@std/internal", - "jsr:@std/path@^1.0.8" + "jsr:@std/path" ] - }, - "@takker/gyazo@0.3.0": { - "integrity": "fb8d602e3d76ac95bc0dc648480ef5165e5e964ecf17a9daea8bda4c0aa0028a", - "dependencies": [ - "npm:option-t@^49.1.0" - ] - }, - "@takker/md5@0.1.0": { - "integrity": "4c423d8247aadf7bcb1eb83c727bf28c05c21906e916517395d00aa157b6eae0" } }, "npm": { @@ -130,11 +133,8 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "option-t@49.3.0": { - "integrity": "sha512-MQFSbqNnjEzQahREx7r1tESmK2UctFK+zmwmnHpBHROJvoRGM9tDMWi53B6ePyFJyAiggRRV9cuXedkpLBeC8w==" - }, - "option-t@50.0.0": { - "integrity": "sha512-zHw9Et+SfAx3Xtl9LagyAjyyzC3pNONEinTAOmZN2IKL0dYa6dthjzwuSRueJ2gLkaTiinQjRDGo/1mKSl70hg==" + "option-t@50.0.2": { + "integrity": "sha512-tIGimxb003CDEqu7f+SJtDDVK5LKAlbLt7q5tW5vFMiBb7QXqESXhgmP5bF+BUnShOKRTBfI/PHZBv7I+NHlig==" }, "socket.io-client@4.8.1": { "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", @@ -169,6 +169,7 @@ "jsr:@std/encoding@1", "jsr:@std/json@1", "jsr:@std/testing@1", + "jsr:@std/testing@^1.0.8", "jsr:@takker/md5@0.1", "npm:option-t@50", "npm:socket.io-client@^4.7.5" diff --git a/docs/migration-guide-0.30.0.md b/docs/migration-guide-0.30.0.md new file mode 100644 index 0000000..ec48a2d --- /dev/null +++ b/docs/migration-guide-0.30.0.md @@ -0,0 +1,148 @@ +# Migration Guide to v0.30.0 + +## Breaking Changes + +### REST API Changes + +The REST API has been completely redesigned to improve type safety, reduce +dependencies, and better align with web standards. The main changes are: + +1. Removal of `option-t` dependency + - All `Result` types from `option-t/plain_result` have been replaced with + `ScrapboxResponse` + - No more `unwrapOk`, `isErr`, or other option-t utilities + +2. New `ScrapboxResponse` class + - Extends the web standard `Response` class + - Direct access to `body`, `headers`, and other standard Response properties + - Type-safe error handling based on HTTP status codes + - Built-in JSON parsing with proper typing for success/error cases + +### Before and After Examples + +#### Before (v0.29.x): + +```typescript +import { isErr, unwrapOk } from "option-t/plain_result"; + +const result = await getProfile(); +if (isErr(result)) { + console.error("Failed:", result); + return; +} +const profile = unwrapOk(result); +console.log("Name:", profile.name); +``` + +#### After (v0.30.0): + +```typescript +const response = await getProfile(); +if (!response.ok) { + console.error("Failed:", response.error); + return; +} +console.log("Name:", response.data.name); +``` + +### Key Benefits + +1. **Simpler Error Handling** + - HTTP status codes determine error types + - No need to unwrap results manually + - Type-safe error objects with proper typing + +2. **Web Standard Compatibility** + - Works with standard web APIs without conversion + - Direct access to Response properties + - Compatible with standard fetch patterns + +3. **Better Type Safety** + - Response types change based on HTTP status + - Proper typing for both success and error cases + - No runtime overhead for type checking + +### Migration Steps + +1. Replace `option-t` imports: + ```diff + - import { isErr, unwrapOk } from "option-t/plain_result"; + ``` + +2. Update error checking: + ```diff + - if (isErr(result)) { + - console.error(result); + + if (!response.ok) { + + console.error(response.error); + ``` + +3. Access response data: + ```diff + - const data = unwrapOk(result); + + const data = response.data; + ``` + +4. For direct Response access: + ```typescript + // Access headers + const contentType = response.headers.get("content-type"); + + // Access raw body + const text = await response.text(); + + // Parse JSON with type safety + const json = await response.json(); + ``` + +### Common Patterns + +1. **Status-based Error Handling**: + +```typescript +const response = await getSnapshot(project, pageId, timestampId); + +if (response.status === 422) { + // Handle invalid snapshot ID + console.error("Invalid snapshot:", response.error); + return; +} + +if (!response.ok) { + // Handle other errors + console.error("Failed:", response.error); + return; +} + +// Use the data +console.log(response.data); +``` + +2. **Type-safe JSON Parsing**: + +```typescript +const response = await getTweetInfo(tweetUrl); +if (response.ok) { + const tweet = response.data; // Properly typed as TweetInfo + console.log(tweet.text); +} +``` + +3. **Working with Headers**: + +```typescript +const response = await uploadToGCS(file, projectId); +if (!response.ok && response.headers.get("Content-Type")?.includes("/xml")) { + console.error("GCS Error:", await response.text()); + return; +} +``` + +### Need Help? + +If you encounter any issues during migration, please: + +1. Check the examples in this guide +2. Review the + [API documentation](https://jsr.io/@takker/scrapbox-userscript-std) +3. Open an issue on GitHub if you need further assistance diff --git a/rest-api-redesign.patch b/rest-api-redesign.patch new file mode 100644 index 0000000..340a047 --- /dev/null +++ b/rest-api-redesign.patch @@ -0,0 +1,2170 @@ +From 74b49ca50adc9311ac5dfb3a2b6412c9d45f3aa0 Mon Sep 17 00:00:00 2001 +From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> +Date: Thu, 2 Jan 2025 07:16:09 +0000 +Subject: [PATCH] refactor(rest): redesign REST API to use ScrapboxResponse + +BREAKING CHANGE: Replace option-t Result with ScrapboxResponse + +- Remove option-t dependency +- Add ScrapboxResponse class extending web standard Response +- Improve type safety with status-based type switching +- Allow direct access to Response.body and headers +- Add migration guide for v0.30.0 + +This change follows the implementation pattern from @takker/gyazo@0.4.0 +and prepares for release as version 0.30.0. + +Resolves #213 +--- + docs/migration-guide-0.30.0.md | 139 +++++++++++++++++++++++++++++++++ + rest/auth.ts | 12 +-- + rest/getCodeBlock.ts | 52 ++++++------ + rest/getGyazoToken.ts | 29 +++---- + rest/getTweetInfo.ts | 52 +++++------- + rest/getWebPageTitle.ts | 46 +++++------ + rest/link.ts | 78 ++++++++---------- + rest/page-data.ts | 55 ++++++------- + rest/pages.ts | 136 ++++++++++++++------------------ + rest/parseHTTPError.ts | 50 ++++++------ + rest/profile.ts | 35 +++------ + rest/project.ts | 89 +++++++++------------ + rest/replaceLinks.ts | 50 +++++------- + rest/response.ts | 105 +++++++++++++++++++++++++ + rest/robustFetch.ts | 19 ++--- + rest/search.ts | 82 +++++++------------ + rest/snapshot.ts | 67 ++++++---------- + rest/table.ts | 57 +++++++------- + rest/uploadToGCS.ts | 130 +++++++++++++----------------- + 19 files changed, 682 insertions(+), 601 deletions(-) + create mode 100644 docs/migration-guide-0.30.0.md + create mode 100644 rest/response.ts + +diff --git a/docs/migration-guide-0.30.0.md b/docs/migration-guide-0.30.0.md +new file mode 100644 +index 0000000..cddf75c +--- /dev/null ++++ b/docs/migration-guide-0.30.0.md +@@ -0,0 +1,139 @@ ++# Migration Guide to v0.30.0 ++ ++## Breaking Changes ++ ++### REST API Changes ++ ++The REST API has been completely redesigned to improve type safety, reduce dependencies, and better align with web standards. The main changes are: ++ ++1. Removal of `option-t` dependency ++ - All `Result` types from `option-t/plain_result` have been replaced with `ScrapboxResponse` ++ - No more `unwrapOk`, `isErr`, or other option-t utilities ++ ++2. New `ScrapboxResponse` class ++ - Extends the web standard `Response` class ++ - Direct access to `body`, `headers`, and other standard Response properties ++ - Type-safe error handling based on HTTP status codes ++ - Built-in JSON parsing with proper typing for success/error cases ++ ++### Before and After Examples ++ ++#### Before (v0.29.x): ++```typescript ++import { isErr, unwrapOk } from "option-t/plain_result"; ++ ++const result = await getProfile(); ++if (isErr(result)) { ++ console.error("Failed:", result); ++ return; ++} ++const profile = unwrapOk(result); ++console.log("Name:", profile.name); ++``` ++ ++#### After (v0.30.0): ++```typescript ++const response = await getProfile(); ++if (!response.ok) { ++ console.error("Failed:", response.error); ++ return; ++} ++console.log("Name:", response.data.name); ++``` ++ ++### Key Benefits ++ ++1. **Simpler Error Handling** ++ - HTTP status codes determine error types ++ - No need to unwrap results manually ++ - Type-safe error objects with proper typing ++ ++2. **Web Standard Compatibility** ++ - Works with standard web APIs without conversion ++ - Direct access to Response properties ++ - Compatible with standard fetch patterns ++ ++3. **Better Type Safety** ++ - Response types change based on HTTP status ++ - Proper typing for both success and error cases ++ - No runtime overhead for type checking ++ ++### Migration Steps ++ ++1. Replace `option-t` imports: ++ ```diff ++ - import { isErr, unwrapOk } from "option-t/plain_result"; ++ ``` ++ ++2. Update error checking: ++ ```diff ++ - if (isErr(result)) { ++ - console.error(result); ++ + if (!response.ok) { ++ + console.error(response.error); ++ ``` ++ ++3. Access response data: ++ ```diff ++ - const data = unwrapOk(result); ++ + const data = response.data; ++ ``` ++ ++4. For direct Response access: ++ ```typescript ++ // Access headers ++ const contentType = response.headers.get("content-type"); ++ ++ // Access raw body ++ const text = await response.text(); ++ ++ // Parse JSON with type safety ++ const json = await response.json(); ++ ``` ++ ++### Common Patterns ++ ++1. **Status-based Error Handling**: ++```typescript ++const response = await getSnapshot(project, pageId, timestampId); ++ ++if (response.status === 422) { ++ // Handle invalid snapshot ID ++ console.error("Invalid snapshot:", response.error); ++ return; ++} ++ ++if (!response.ok) { ++ // Handle other errors ++ console.error("Failed:", response.error); ++ return; ++} ++ ++// Use the data ++console.log(response.data); ++``` ++ ++2. **Type-safe JSON Parsing**: ++```typescript ++const response = await getTweetInfo(tweetUrl); ++if (response.ok) { ++ const tweet = response.data; // Properly typed as TweetInfo ++ console.log(tweet.text); ++} ++``` ++ ++3. **Working with Headers**: ++```typescript ++const response = await uploadToGCS(file, projectId); ++if (!response.ok && response.headers.get("Content-Type")?.includes("/xml")) { ++ console.error("GCS Error:", await response.text()); ++ return; ++} ++``` ++ ++### Need Help? ++ ++If you encounter any issues during migration, please: ++1. Check the examples in this guide ++2. Review the [API documentation](https://jsr.io/@takker/scrapbox-userscript-std) ++3. Open an issue on GitHub if you need further assistance +diff --git a/rest/auth.ts b/rest/auth.ts +index 65df74c..40285e9 100644 +--- a/rest/auth.ts ++++ b/rest/auth.ts +@@ -1,5 +1,5 @@ +-import { createOk, mapForResult, type Result } from "option-t/plain_result"; + import { getProfile } from "./profile.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import type { HTTPError } from "./responseIntoResult.ts"; + import type { AbortError, NetworkError } from "./robustFetch.ts"; + import type { ExtendedOptions } from "./options.ts"; +@@ -16,11 +16,11 @@ export const cookie = (sid: string): string => `connect.sid=${sid}`; + */ + export const getCSRFToken = async ( + init?: ExtendedOptions, +-): Promise> => { ++): Promise> => { + // deno-lint-ignore no-explicit-any + const csrf = init?.csrf ?? (globalThis as any)._csrf; +- return csrf ? createOk(csrf) : mapForResult( +- await getProfile(init), +- (user) => user.csrfToken, +- ); ++ if (csrf) return ScrapboxResponse.ok(csrf); ++ ++ const profile = await getProfile(init); ++ return profile.ok ? ScrapboxResponse.ok(profile.data.csrfToken) : profile; + }; +diff --git a/rest/getCodeBlock.ts b/rest/getCodeBlock.ts +index 42a25f4..0a61a6a 100644 +--- a/rest/getCodeBlock.ts ++++ b/rest/getCodeBlock.ts +@@ -6,14 +6,7 @@ import type { + import { cookie } from "./auth.ts"; + import { encodeTitleURI } from "../title.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; + import type { FetchError } from "./mod.ts"; + +@@ -33,21 +26,28 @@ const getCodeBlock_toRequest: GetCodeBlock["toRequest"] = ( + ); + }; + +-const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => +- mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(res), +- async (res) => +- res.response.status === 404 && +- res.response.headers.get("Content-Type")?.includes?.("text/plain") +- ? { name: "NotFoundError", message: "Code block is not found" } +- : (await parseHTTPError(res, [ +- "NotLoggedInError", +- "NotMemberError", +- ])) ?? res, +- ), +- (res) => res.text(), +- ); ++const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => { ++ const response = ScrapboxResponse.from(res); ++ ++ if (response.status === 404 && response.headers.get("Content-Type")?.includes?.("text/plain")) { ++ return ScrapboxResponse.error({ ++ name: "NotFoundError", ++ message: "Code block is not found", ++ }); ++ } ++ ++ await parseHTTPError(response, [ ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ if (response.ok) { ++ const text = await response.text(); ++ return ScrapboxResponse.ok(text); ++ } ++ ++ return response; ++}; + + export interface GetCodeBlock { + /** /api/code/:project/:title/:filename の要求を組み立てる +@@ -70,14 +70,14 @@ export interface GetCodeBlock { + * @param res 応答 + * @return コード + */ +- fromResponse: (res: Response) => Promise>; ++ fromResponse: (res: Response) => Promise>; + + ( + project: string, + title: string, + filename: string, + options?: BaseOptions, +- ): Promise>; ++ ): Promise>; + } + export type CodeBlockError = + | NotFoundError +@@ -101,7 +101,7 @@ export const getCodeBlock: GetCodeBlock = /* @__PURE__ */ (() => { + ) => { + const req = getCodeBlock_toRequest(project, title, filename, options); + const res = await setDefaults(options ?? {}).fetch(req); +- return isErr(res) ? res : getCodeBlock_fromResponse(unwrapOk(res)); ++ return getCodeBlock_fromResponse(res); + }; + + fn.toRequest = getCodeBlock_toRequest; +diff --git a/rest/getGyazoToken.ts b/rest/getGyazoToken.ts +index b9bbac2..1d0f3d6 100644 +--- a/rest/getGyazoToken.ts ++++ b/rest/getGyazoToken.ts +@@ -1,14 +1,7 @@ +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { NotLoggedInError } from "@cosense/types/rest"; + import { cookie } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; + import type { FetchError } from "./mod.ts"; + +@@ -29,7 +22,7 @@ export type GyazoTokenError = NotLoggedInError | HTTPError; + */ + export const getGyazoToken = async ( + init?: GetGyazoTokenOptions, +-): Promise> => { ++): Promise> => { + const { fetch, sid, hostName, gyazoTeamsName } = setDefaults(init ?? {}); + const req = new Request( + `https://${hostName}/api/login/gyazo/oauth-upload/token${ +@@ -39,14 +32,14 @@ export const getGyazoToken = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; ++ const response = ScrapboxResponse.from(res); + +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- (await parseHTTPError(error, ["NotLoggedInError"])) ?? error, +- ), +- (res) => res.json().then((json) => json.token as string | undefined), +- ); ++ await parseHTTPError(response, ["NotLoggedInError"]); ++ ++ if (response.ok) { ++ const json = await response.json(); ++ return ScrapboxResponse.ok(json.token as string | undefined); ++ } ++ ++ return response; + }; +diff --git a/rest/getTweetInfo.ts b/rest/getTweetInfo.ts +index 48408d9..c14e5a7 100644 +--- a/rest/getTweetInfo.ts ++++ b/rest/getTweetInfo.ts +@@ -1,10 +1,3 @@ +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { + BadRequestError, + InvalidURLError, +@@ -13,7 +6,7 @@ import type { + } from "@cosense/types/rest"; + import { cookie, getCSRFToken } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { type ExtendedOptions, setDefaults } from "./options.ts"; + import type { FetchError } from "./mod.ts"; + +@@ -32,11 +25,11 @@ export type TweetInfoError = + export const getTweetInfo = async ( + url: string | URL, + init?: ExtendedOptions, +-): Promise> => { ++): Promise> => { + const { sid, hostName, fetch } = setDefaults(init ?? {}); + +- const csrfResult = await getCSRFToken(init); +- if (isErr(csrfResult)) return csrfResult; ++ const csrfToken = await getCSRFToken(init); ++ if (!csrfToken.ok) return csrfToken; + + const req = new Request( + `https://${hostName}/api/embed-text/twitter?url=${ +@@ -46,7 +39,7 @@ export const getTweetInfo = async ( + method: "POST", + headers: { + "Content-Type": "application/json;charset=utf-8", +- "X-CSRF-TOKEN": unwrapOk(csrfResult), ++ "X-CSRF-TOKEN": csrfToken.data, + ...(sid ? { Cookie: cookie(sid) } : {}), + }, + body: JSON.stringify({ timeout: 3000 }), +@@ -54,25 +47,20 @@ export const getTweetInfo = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; ++ const response = ScrapboxResponse.from(res); + +- return mapErrAsyncForResult( +- await mapAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- (res) => res.json() as Promise, +- ), +- async (res) => { +- if (res.response.status === 422) { +- return { +- name: "InvalidURLError", +- message: (await res.response.json()).message as string, +- }; +- } +- const parsed = await parseHTTPError(res, [ +- "SessionError", +- "BadRequestError", +- ]); +- return parsed ?? res; +- }, +- ); ++ if (response.status === 422) { ++ const json = await response.json(); ++ return ScrapboxResponse.error({ ++ name: "InvalidURLError", ++ message: json.message as string, ++ }); ++ } ++ ++ await parseHTTPError(response, [ ++ "SessionError", ++ "BadRequestError", ++ ]); ++ ++ return response; + }; +diff --git a/rest/getWebPageTitle.ts b/rest/getWebPageTitle.ts +index c523aff..f01afad 100644 +--- a/rest/getWebPageTitle.ts ++++ b/rest/getWebPageTitle.ts +@@ -1,10 +1,3 @@ +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { + BadRequestError, + InvalidURLError, +@@ -12,7 +5,7 @@ import type { + } from "@cosense/types/rest"; + import { cookie, getCSRFToken } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { type ExtendedOptions, setDefaults } from "./options.ts"; + import type { FetchError } from "./mod.ts"; + +@@ -31,11 +24,11 @@ export type WebPageTitleError = + export const getWebPageTitle = async ( + url: string | URL, + init?: ExtendedOptions, +-): Promise> => { ++): Promise> => { + const { sid, hostName, fetch } = setDefaults(init ?? {}); + +- const csrfResult = await getCSRFToken(init); +- if (isErr(csrfResult)) return csrfResult; ++ const csrfToken = await getCSRFToken(init); ++ if (!csrfToken.ok) return csrfToken; + + const req = new Request( + `https://${hostName}/api/embed-text/url?url=${ +@@ -45,7 +38,7 @@ export const getWebPageTitle = async ( + method: "POST", + headers: { + "Content-Type": "application/json;charset=utf-8", +- "X-CSRF-TOKEN": unwrapOk(csrfResult), ++ "X-CSRF-TOKEN": csrfToken.data, + ...(sid ? { Cookie: cookie(sid) } : {}), + }, + body: JSON.stringify({ timeout: 3000 }), +@@ -53,21 +46,18 @@ export const getWebPageTitle = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; ++ const response = ScrapboxResponse.from(res); + +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- (await parseHTTPError(error, [ +- "SessionError", +- "BadRequestError", +- "InvalidURLError", +- ])) ?? error, +- ), +- async (res) => { +- const { title } = (await res.json()) as { title: string }; +- return title; +- }, +- ); ++ await parseHTTPError(response, [ ++ "SessionError", ++ "BadRequestError", ++ "InvalidURLError", ++ ]); ++ ++ if (response.ok) { ++ const { title } = await response.json() as { title: string }; ++ return ScrapboxResponse.ok(title); ++ } ++ ++ return response; + }; +diff --git a/rest/link.ts b/rest/link.ts +index 49cbbd3..3463e1c 100644 +--- a/rest/link.ts ++++ b/rest/link.ts +@@ -1,11 +1,3 @@ +-import { +- createOk, +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { + ErrorLike, + NotFoundError, +@@ -14,7 +6,7 @@ import type { + } from "@cosense/types/rest"; + import { cookie } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; + import type { FetchError } from "./mod.ts"; + +@@ -49,7 +41,7 @@ export interface GetLinks { + ( + project: string, + options?: GetLinksOptions, +- ): Promise>; ++ ): Promise>; + + /** Create a request to `GET /api/pages/:project/search/titles` + * +@@ -66,7 +58,7 @@ export interface GetLinks { + */ + fromResponse: ( + response: Response, +- ) => Promise>; ++ ) => Promise>; + } + + const getLinks_toRequest: GetLinks["toRequest"] = (project, options) => { +@@ -80,27 +72,27 @@ const getLinks_toRequest: GetLinks["toRequest"] = (project, options) => { + ); + }; + +-const getLinks_fromResponse: GetLinks["fromResponse"] = async (response) => +- mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(response), +- async (error) => +- error.response.status === 422 +- ? { +- name: "InvalidFollowingIdError", +- message: await error.response.text(), +- } as InvalidFollowingIdError +- : (await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- ])) ?? error, +- ), +- (res) => +- res.json().then((pages: SearchedTitle[]) => ({ +- pages, +- followingId: res.headers.get("X-following-id") ?? "", +- })), +- ); ++const getLinks_fromResponse: GetLinks["fromResponse"] = async (response) => { ++ const res = ScrapboxResponse.from(response); ++ ++ if (res.status === 422) { ++ return ScrapboxResponse.error({ ++ name: "InvalidFollowingIdError", ++ message: await response.text(), ++ } as InvalidFollowingIdError); ++ } ++ ++ await parseHTTPError(res, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ ]); ++ ++ const pages = await res.json() as SearchedTitle[]; ++ return ScrapboxResponse.ok({ ++ pages, ++ followingId: response.headers.get("X-following-id") ?? "", ++ }); ++}; + + /** 指定したprojectのリンクデータを取得する + * +@@ -108,11 +100,10 @@ const getLinks_fromResponse: GetLinks["fromResponse"] = async (response) => + */ + export const getLinks: GetLinks = /* @__PURE__ */ (() => { + const fn: GetLinks = async (project, options) => { +- const res = await setDefaults(options ?? {}).fetch( ++ const response = await setDefaults(options ?? {}).fetch( + getLinks_toRequest(project, options), + ); +- if (isErr(res)) return res; +- return getLinks_fromResponse(unwrapOk(res)); ++ return getLinks_fromResponse(response); + }; + + fn.toRequest = getLinks_toRequest; +@@ -131,21 +122,20 @@ export async function* readLinksBulk( + project: string, + options?: BaseOptions, + ): AsyncGenerator< +- Result, ++ ScrapboxResponse, + void, + unknown + > { + let followingId: string | undefined; + do { + const result = await getLinks(project, { followingId, ...options }); +- if (isErr(result)) { ++ if (!result.ok) { + yield result; + return; + } +- const res = unwrapOk(result); + +- yield createOk(res.pages); +- followingId = res.followingId; ++ yield ScrapboxResponse.ok(result.data.pages); ++ followingId = result.data.followingId; + } while (followingId); + } + +@@ -158,17 +148,17 @@ export async function* readLinks( + project: string, + options?: BaseOptions, + ): AsyncGenerator< +- Result, ++ ScrapboxResponse, + void, + unknown + > { + for await (const result of readLinksBulk(project, options)) { +- if (isErr(result)) { ++ if (!result.ok) { + yield result; + return; + } +- for (const page of unwrapOk(result)) { +- yield createOk(page); ++ for (const page of result.data) { ++ yield ScrapboxResponse.ok(page); + } + } + } +diff --git a/rest/page-data.ts b/rest/page-data.ts +index 13d43e2..a82e1e3 100644 +--- a/rest/page-data.ts ++++ b/rest/page-data.ts +@@ -1,11 +1,3 @@ +-import { +- createOk, +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { + ExportedData, + ImportedData, +@@ -15,7 +7,7 @@ import type { + } from "@cosense/types/rest"; + import { cookie, getCSRFToken } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { + type BaseOptions, + type ExtendedOptions, +@@ -35,9 +27,9 @@ export const importPages = async ( + data: ImportedData, + init?: ExtendedOptions, + ): Promise< +- Result ++ ScrapboxResponse + > => { +- if (data.pages.length === 0) return createOk("No pages to import."); ++ if (data.pages.length === 0) return ScrapboxResponse.ok("No pages to import."); + + const { sid, hostName, fetch } = setDefaults(init ?? {}); + const formData = new FormData(); +@@ -49,8 +41,8 @@ export const importPages = async ( + ); + formData.append("name", "undefined"); + +- const csrfResult = await getCSRFToken(init); +- if (isErr(csrfResult)) return csrfResult; ++ const csrfToken = await getCSRFToken(init); ++ if (!csrfToken.ok) return csrfToken; + + const req = new Request( + `https://${hostName}/api/page-data/import/${project}.json`, +@@ -59,19 +51,21 @@ export const importPages = async ( + headers: { + ...(sid ? { Cookie: cookie(sid) } : {}), + Accept: "application/json, text/plain, */*", +- "X-CSRF-TOKEN": unwrapOk(csrfResult), ++ "X-CSRF-TOKEN": csrfToken.data, + }, + body: formData, + }, + ); + + const res = await fetch(req); +- if (isErr(res)) return res; ++ const response = ScrapboxResponse.from(res); + +- return mapAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (res) => (await res.json()).message as string, +- ); ++ if (response.ok) { ++ const json = await response.json(); ++ return ScrapboxResponse.ok(json.message as string); ++ } ++ ++ return response; + }; + + export type ExportPagesError = +@@ -93,7 +87,7 @@ export const exportPages = async ( + project: string, + init: ExportInit, + ): Promise< +- Result, ExportPagesError | FetchError> ++ ScrapboxResponse, ExportPagesError | FetchError> + > => { + const { sid, hostName, fetch, metadata } = setDefaults(init ?? {}); + +@@ -102,18 +96,13 @@ export const exportPages = async ( + sid ? { headers: { Cookie: cookie(sid) } } : undefined, + ); + const res = await fetch(req); +- if (isErr(res)) return res; ++ const response = ScrapboxResponse.from, ExportPagesError>(res); + +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- (await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotPrivilegeError", +- ])) ?? error, +- ), +- (res) => res.json() as Promise>, +- ); ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotPrivilegeError", ++ ]); ++ ++ return response; + }; +diff --git a/rest/pages.ts b/rest/pages.ts +index bc86592..01df0b1 100644 +--- a/rest/pages.ts ++++ b/rest/pages.ts +@@ -10,14 +10,7 @@ import { cookie } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; + import { encodeTitleURI } from "../title.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; +-import { +- andThenAsyncForResult, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +-} from "option-t/plain_result"; +-import { unwrapOrForMaybe } from "option-t/maybe"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import type { FetchError } from "./robustFetch.ts"; + + /** Options for `getPage()` */ +@@ -53,39 +46,31 @@ const getPage_toRequest: GetPage["toRequest"] = ( + ); + }; + +-const getPage_fromResponse: GetPage["fromResponse"] = async (res) => +- mapErrAsyncForResult( +- await mapAsyncForResult( +- responseIntoResult(res), +- (res) => res.json() as Promise, +- ), +- async ( +- error, +- ) => { +- if (error.response.status === 414) { +- return { +- name: "TooLongURIError", +- message: "project ids may be too much.", +- }; +- } +- +- return unwrapOrForMaybe( +- await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotMemberError", +- ]), +- error, +- ); +- }, +- ); ++const getPage_fromResponse: GetPage["fromResponse"] = async (res) => { ++ const response = ScrapboxResponse.from(res); ++ ++ if (response.status === 414) { ++ return ScrapboxResponse.error({ ++ name: "TooLongURIError", ++ message: "project ids may be too much.", ++ }); ++ } ++ ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ return response; ++}; + + export interface GetPage { +- /** /api/pages/:project/:title の要求を組み立てる ++ /** Build request for /api/pages/:project/:title + * +- * @param project 取得したいページのproject名 +- * @param title 取得したいページのtitle 大文字小文字は問わない +- * @param options オプション ++ * @param project Project name to get page from ++ * @param title Page title (case insensitive) ++ * @param options Additional options + * @return request + */ + toRequest: ( +@@ -94,18 +79,18 @@ export interface GetPage { + options?: GetPageOption, + ) => Request; + +- /** 帰ってきた応答からページのJSONデータを取得する ++ /** Get page JSON data from response + * +- * @param res 応答 +- * @return ページのJSONデータ ++ * @param res Response object ++ * @return Page JSON data + */ +- fromResponse: (res: Response) => Promise>; ++ fromResponse: (res: Response) => Promise>; + + ( + project: string, + title: string, + options?: GetPageOption, +- ): Promise>; ++ ): Promise>; + } + + export type PageError = +@@ -126,13 +111,12 @@ export const getPage: GetPage = /* @__PURE__ */ (() => { + project, + title, + options, +- ) => +- andThenAsyncForResult( +- await setDefaults(options ?? {}).fetch( +- getPage_toRequest(project, title, options), +- ), +- (input) => getPage_fromResponse(input), ++ ) => { ++ const response = await setDefaults(options ?? {}).fetch( ++ getPage_toRequest(project, title, options), + ); ++ return getPage_fromResponse(response); ++ }; + + fn.toRequest = getPage_toRequest; + fn.fromResponse = getPage_fromResponse; +@@ -168,10 +152,10 @@ export interface ListPagesOption extends BaseOptions { + } + + export interface ListPages { +- /** /api/pages/:project の要求を組み立てる ++ /** Build request for /api/pages/:project + * +- * @param project 取得したいページのproject名 +- * @param options オプション ++ * @param project Project name to list pages from ++ * @param options Additional options + * @return request + */ + toRequest: ( +@@ -179,17 +163,17 @@ export interface ListPages { + options?: ListPagesOption, + ) => Request; + +- /** 帰ってきた応答からページのJSONデータを取得する ++ /** Get page list JSON data from response + * +- * @param res 応答 +- * @return ページのJSONデータ ++ * @param res Response object ++ * @return Page list JSON data + */ +- fromResponse: (res: Response) => Promise>; ++ fromResponse: (res: Response) => Promise>; + + ( + project: string, + options?: ListPagesOption, +- ): Promise>; ++ ): Promise>; + } + + export type ListPagesError = +@@ -213,22 +197,17 @@ const listPages_toRequest: ListPages["toRequest"] = (project, options) => { + ); + }; + +-const listPages_fromResponse: ListPages["fromResponse"] = async (res) => +- mapErrAsyncForResult( +- await mapAsyncForResult( +- responseIntoResult(res), +- (res) => res.json() as Promise, +- ), +- async (error) => +- unwrapOrForMaybe( +- await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotMemberError", +- ]), +- error, +- ), +- ); ++const listPages_fromResponse: ListPages["fromResponse"] = async (res) => { ++ const response = ScrapboxResponse.from(res); ++ ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ return response; ++}; + + /** 指定したprojectのページを一覧する + * +@@ -239,13 +218,12 @@ export const listPages: ListPages = /* @__PURE__ */ (() => { + const fn: ListPages = async ( + project, + options?, +- ) => +- andThenAsyncForResult( +- await setDefaults(options ?? {})?.fetch( +- listPages_toRequest(project, options), +- ), +- listPages_fromResponse, ++ ) => { ++ const response = await setDefaults(options ?? {})?.fetch( ++ listPages_toRequest(project, options), + ); ++ return listPages_fromResponse(response); ++ }; + + fn.toRequest = listPages_toRequest; + fn.fromResponse = listPages_fromResponse; +diff --git a/rest/parseHTTPError.ts b/rest/parseHTTPError.ts +index 003ed9b..54f8a44 100644 +--- a/rest/parseHTTPError.ts ++++ b/rest/parseHTTPError.ts +@@ -8,13 +8,11 @@ import type { + NotPrivilegeError, + SessionError, + } from "@cosense/types/rest"; +-import type { Maybe } from "option-t/maybe"; + import { isArrayOf } from "@core/unknownutil/is/array-of"; + import { isLiteralOneOf } from "@core/unknownutil/is/literal-one-of"; + import { isRecord } from "@core/unknownutil/is/record"; + import { isString } from "@core/unknownutil/is/string"; +- +-import type { HTTPError } from "./responseIntoResult.ts"; ++import type { ScrapboxResponse } from "./response.ts"; + + export interface RESTfullAPIErrorMap { + BadRequestError: BadRequestError; +@@ -27,20 +25,22 @@ export interface RESTfullAPIErrorMap { + NotPrivilegeError: NotPrivilegeError; + } + +-/** 失敗した要求からエラー情報を取り出す */ ++/** Extract error information from a failed request */ + export const parseHTTPError = async < + ErrorNames extends keyof RESTfullAPIErrorMap, ++ T = unknown, ++ E = unknown, + >( +- error: HTTPError, ++ response: ScrapboxResponse, + errorNames: ErrorNames[], +-): Promise> => { +- const res = error.response.clone(); ++): Promise => { ++ const res = response.clone(); + const isErrorNames = isLiteralOneOf(errorNames); + try { + const json: unknown = await res.json(); +- if (!isRecord(json)) return; ++ if (!isRecord(json)) return undefined; + if (res.status === 422) { +- if (!isString(json.message)) return; ++ if (!isString(json.message)) return undefined; + for ( + const name of [ + "NoQueryError", +@@ -48,34 +48,40 @@ export const parseHTTPError = async < + ] as (keyof RESTfullAPIErrorMap)[] + ) { + if (!(errorNames as string[]).includes(name)) continue; +- return { ++ const error = { + name, + message: json.message, +- } as unknown as RESTfullAPIErrorMap[ErrorNames]; ++ } as RESTfullAPIErrorMap[ErrorNames]; ++ Object.assign(response, { error }); ++ return error; + } + } +- if (!isErrorNames(json.name)) return; +- if (!isString(json.message)) return; ++ if (!isErrorNames(json.name)) return undefined; ++ if (!isString(json.message)) return undefined; + if (json.name === "NotLoggedInError") { +- if (!isRecord(json.detals)) return; +- if (!isString(json.detals.project)) return; +- if (!isArrayOf(isLoginStrategies)(json.detals.loginStrategies)) return; +- return { ++ if (!isRecord(json.detals)) return undefined; ++ if (!isString(json.detals.project)) return undefined; ++ if (!isArrayOf(isLoginStrategies)(json.detals.loginStrategies)) return undefined; ++ const error = { + name: json.name, + message: json.message, + details: { + project: json.detals.project, + loginStrategies: json.detals.loginStrategies, + }, +- } as unknown as RESTfullAPIErrorMap[ErrorNames]; ++ } as RESTfullAPIErrorMap[ErrorNames]; ++ Object.assign(response, { error }); ++ return error; + } +- return { ++ const error = { + name: json.name, + message: json.message, +- } as unknown as RESTfullAPIErrorMap[ErrorNames]; ++ } as RESTfullAPIErrorMap[ErrorNames]; ++ Object.assign(response, { error }); ++ return error; + } catch (e: unknown) { +- if (e instanceof SyntaxError) return; +- // JSONのparse error以外はそのまま投げる ++ if (e instanceof SyntaxError) return undefined; ++ // Re-throw non-JSON parse errors + throw e; + } + }; +diff --git a/rest/profile.ts b/rest/profile.ts +index 6e29310..dd684b0 100644 +--- a/rest/profile.ts ++++ b/rest/profile.ts +@@ -1,36 +1,30 @@ +-import { +- isErr, +- mapAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { GuestUser, MemberUser } from "@cosense/types/rest"; + import { cookie } from "./auth.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import type { FetchError } from "./robustFetch.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; + + export interface GetProfile { +- /** /api/users/me の要求を組み立てる ++ /** Build request for /api/users/me + * + * @param init connect.sid etc. + * @return request + */ + toRequest: (init?: BaseOptions) => Request; + +- /** get the user profile from the given response ++ /** Get user profile from response + * +- * @param res response +- * @return user profile ++ * @param res Response object ++ * @return User profile + */ + fromResponse: ( + res: Response, + ) => Promise< +- Result ++ ScrapboxResponse + >; + + (init?: BaseOptions): Promise< +- Result ++ ScrapboxResponse + >; + } + +@@ -46,20 +40,17 @@ const getProfile_toRequest: GetProfile["toRequest"] = ( + ); + }; + +-const getProfile_fromResponse: GetProfile["fromResponse"] = (response) => +- mapAsyncForResult( +- responseIntoResult(response), +- async (res) => (await res.json()) as MemberUser | GuestUser, +- ); ++const getProfile_fromResponse: GetProfile["fromResponse"] = async (res) => { ++ const response = ScrapboxResponse.from(res); ++ return response; ++}; + + export const getProfile: GetProfile = /* @__PURE__ */ (() => { + const fn: GetProfile = async (init) => { + const { fetch, ...rest } = setDefaults(init ?? {}); + +- const resResult = await fetch(getProfile_toRequest(rest)); +- return isErr(resResult) +- ? resResult +- : getProfile_fromResponse(unwrapOk(resResult)); ++ const response = await fetch(getProfile_toRequest(rest)); ++ return getProfile_fromResponse(response); + }; + + fn.toRequest = getProfile_toRequest; +diff --git a/rest/project.ts b/rest/project.ts +index 24de85d..b840414 100644 +--- a/rest/project.ts ++++ b/rest/project.ts +@@ -1,10 +1,3 @@ +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { + MemberProject, + NotFoundError, +@@ -16,14 +9,14 @@ import type { + } from "@cosense/types/rest"; + import { cookie } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import type { FetchError } from "./robustFetch.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; + + export interface GetProject { +- /** /api/project/:project の要求を組み立てる ++ /** Build request for /api/project/:project + * +- * @param project project name to get ++ * @param project Project name to get + * @param init connect.sid etc. + * @return request + */ +@@ -32,20 +25,20 @@ export interface GetProject { + options?: BaseOptions, + ) => Request; + +- /** 帰ってきた応答からprojectのJSONデータを取得する ++ /** Get project JSON data from response + * +- * @param res 応答 +- * @return projectのJSONデータ ++ * @param res Response object ++ * @return Project JSON data + */ + fromResponse: ( + res: Response, +- ) => Promise>; ++ ) => Promise>; + + ( + project: string, + options?: BaseOptions, + ): Promise< +- Result ++ ScrapboxResponse + >; + } + +@@ -64,19 +57,17 @@ const getProject_toRequest: GetProject["toRequest"] = (project, init) => { + ); + }; + +-const getProject_fromResponse: GetProject["fromResponse"] = async (res) => +- mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(res), +- async (error) => +- (await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotMemberError", +- ])) ?? error, +- ), +- (res) => res.json() as Promise, +- ); ++const getProject_fromResponse: GetProject["fromResponse"] = async (res) => { ++ const response = ScrapboxResponse.from(res); ++ ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ return response; ++}; + + /** get the project information + * +@@ -91,10 +82,8 @@ export const getProject: GetProject = /* @__PURE__ */ (() => { + const { fetch } = setDefaults(init ?? {}); + + const req = getProject_toRequest(project, init); +- const res = await fetch(req); +- if (isErr(res)) return res; +- +- return getProject_fromResponse(unwrapOk(res)); ++ const response = await fetch(req); ++ return getProject_fromResponse(response); + }; + + fn.toRequest = getProject_toRequest; +@@ -104,9 +93,9 @@ export const getProject: GetProject = /* @__PURE__ */ (() => { + })(); + + export interface ListProjects { +- /** /api/project の要求を組み立てる ++ /** Build request for /api/project + * +- * @param projectIds project ids. This must have more than 1 id ++ * @param projectIds Project IDs (must have more than 1 ID) + * @param init connect.sid etc. + * @return request + */ +@@ -115,19 +104,19 @@ export interface ListProjects { + init?: BaseOptions, + ) => Request; + +- /** 帰ってきた応答からprojectのJSONデータを取得する ++ /** Get projects JSON data from response + * +- * @param res 応答 +- * @return projectのJSONデータ ++ * @param res Response object ++ * @return Projects JSON data + */ + fromResponse: ( + res: Response, +- ) => Promise>; ++ ) => Promise>; + + ( + projectIds: ProjectId[], + init?: BaseOptions, +- ): Promise>; ++ ): Promise>; + } + + export type ListProjectsError = NotLoggedInError | HTTPError; +@@ -144,15 +133,13 @@ const ListProject_toRequest: ListProjects["toRequest"] = (projectIds, init) => { + ); + }; + +-const ListProject_fromResponse: ListProjects["fromResponse"] = async (res) => +- mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(res), +- async (error) => +- (await parseHTTPError(error, ["NotLoggedInError"])) ?? error, +- ), +- (res) => res.json() as Promise, +- ); ++const ListProject_fromResponse: ListProjects["fromResponse"] = async (res) => { ++ const response = ScrapboxResponse.from(res); ++ ++ await parseHTTPError(response, ["NotLoggedInError"]); ++ ++ return response; ++}; + + /** list the projects' information + * +@@ -166,10 +153,8 @@ export const listProjects: ListProjects = /* @__PURE__ */ (() => { + ) => { + const { fetch } = setDefaults(init ?? {}); + +- const res = await fetch(ListProject_toRequest(projectIds, init)); +- if (isErr(res)) return res; +- +- return ListProject_fromResponse(unwrapOk(res)); ++ const response = await fetch(ListProject_toRequest(projectIds, init)); ++ return ListProject_fromResponse(response); + }; + + fn.toRequest = ListProject_toRequest; +diff --git a/rest/replaceLinks.ts b/rest/replaceLinks.ts +index b89e43e..6d3ef92 100644 +--- a/rest/replaceLinks.ts ++++ b/rest/replaceLinks.ts +@@ -1,10 +1,3 @@ +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { + NotFoundError, + NotLoggedInError, +@@ -12,7 +5,7 @@ import type { + } from "@cosense/types/rest"; + import { cookie, getCSRFToken } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import type { FetchError } from "./robustFetch.ts"; + import { type ExtendedOptions, setDefaults } from "./options.ts"; + +@@ -38,11 +31,11 @@ export const replaceLinks = async ( + from: string, + to: string, + init?: ExtendedOptions, +-): Promise> => { ++): Promise> => { + const { sid, hostName, fetch } = setDefaults(init ?? {}); + +- const csrfResult = await getCSRFToken(init); +- if (isErr(csrfResult)) return csrfResult; ++ const csrfToken = await getCSRFToken(init); ++ if (!csrfToken.ok) return csrfToken; + + const req = new Request( + `https://${hostName}/api/pages/${project}/replace/links`, +@@ -50,30 +43,27 @@ export const replaceLinks = async ( + method: "POST", + headers: { + "Content-Type": "application/json;charset=utf-8", +- "X-CSRF-TOKEN": unwrapOk(csrfResult), ++ "X-CSRF-TOKEN": csrfToken.data, + ...(sid ? { Cookie: cookie(sid) } : {}), + }, + body: JSON.stringify({ from, to }), + }, + ); + +- const resResult = await fetch(req); +- if (isErr(resResult)) return resResult; ++ const res = await fetch(req); ++ const response = ScrapboxResponse.from(res); + +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(resResult)), +- async (error) => +- (await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotMemberError", +- ])) ?? error, +- ), +- async (res) => { +- // messageには"2 pages have been successfully updated!"というような文字列が入っているはず +- const { message } = (await res.json()) as { message: string }; +- return parseInt(message.match(/\d+/)?.[0] ?? "0"); +- }, +- ); ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ if (response.ok) { ++ // The message contains text like "2 pages have been successfully updated!" ++ const { message } = await response.json() as { message: string }; ++ return ScrapboxResponse.ok(parseInt(message.match(/\d+/)?.[0] ?? "0")); ++ } ++ ++ return response; + }; +diff --git a/rest/response.ts b/rest/response.ts +new file mode 100644 +index 0000000..ebf2e76 +--- /dev/null ++++ b/rest/response.ts +@@ -0,0 +1,105 @@ ++import type { ++ BadRequestError, ++ InvalidURLError, ++ NoQueryError, ++ NotFoundError, ++ NotLoggedInError, ++ NotMemberError, ++ NotPrivilegeError, ++ SessionError, ++} from "@cosense/types/rest"; ++ ++/** ++ * A type-safe response class that extends the web standard Response. ++ * It provides status-based type switching and direct access to Response properties. ++ */ ++export class ScrapboxResponse extends Response { ++ error?: E; ++ ++ constructor(response: Response) { ++ super(response.body, { ++ status: response.status, ++ statusText: response.statusText, ++ headers: response.headers, ++ }); ++ } ++ ++ /** ++ * Parse the response body as JSON with type safety based on status code. ++ * Returns T for successful responses (2xx) and E for error responses. ++ */ ++ async json(): Promise { ++ const data = await super.json(); ++ return data as T; ++ } ++ ++ /** ++ * Create a new ScrapboxResponse instance from a Response. ++ */ ++ static from(response: Response): ScrapboxResponse { ++ if (response instanceof ScrapboxResponse) { ++ return response; ++ } ++ return new ScrapboxResponse(response); ++ } ++ ++ /** ++ * Create a new error response with the given error details. ++ */ ++ static error( ++ error: E, ++ init?: ResponseInit, ++ ): ScrapboxResponse { ++ const response = new ScrapboxResponse( ++ new Response(null, { ++ status: 400, ++ ...init, ++ }), ++ ); ++ Object.assign(response, { error }); ++ return response; ++ } ++ ++ /** ++ * Create a new success response with the given data. ++ */ ++ static success( ++ data: T, ++ init?: ResponseInit, ++ ): ScrapboxResponse { ++ return new ScrapboxResponse( ++ new Response(JSON.stringify(data), { ++ status: 200, ++ headers: { ++ "Content-Type": "application/json", ++ }, ++ ...init, ++ }), ++ ); ++ } ++ ++ /** ++ * Clone the response while preserving type information and error details. ++ */ ++ clone(): ScrapboxResponse { ++ const cloned = super.clone(); ++ const response = new ScrapboxResponse(cloned); ++ if (this.error) { ++ Object.assign(response, { error: this.error }); ++ } ++ return response; ++ } ++} ++ ++export type ScrapboxErrorResponse = ScrapboxResponse; ++export type ScrapboxSuccessResponse = ScrapboxResponse; ++ ++export type RESTError = ++ | BadRequestError ++ | NotFoundError ++ | NotLoggedInError ++ | NotMemberError ++ | SessionError ++ | InvalidURLError ++ | NoQueryError ++ | NotPrivilegeError; +diff --git a/rest/robustFetch.ts b/rest/robustFetch.ts +index e9b5305..8bd4c3e 100644 +--- a/rest/robustFetch.ts ++++ b/rest/robustFetch.ts +@@ -1,4 +1,4 @@ +-import { createErr, createOk, type Result } from "option-t/plain_result"; ++import { ScrapboxResponse } from "./response.ts"; + + export interface NetworkError { + name: "NetworkError"; +@@ -19,38 +19,39 @@ export type FetchError = NetworkError | AbortError; + * + * @param input - The resource URL or a {@linkcode Request} object. + * @param init - An optional object containing request options. +- * @returns A promise that resolves to a {@linkcode Result} object containing either a {@linkcode Request} or an error. ++ * @returns A promise that resolves to a {@linkcode ScrapboxResponse} object. + */ + export type RobustFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +-) => Promise>; ++) => Promise>; + + /** + * A simple implementation of {@linkcode RobustFetch} that uses {@linkcode fetch}. + * + * @param input - The resource URL or a {@linkcode Request} object. + * @param init - An optional object containing request options. +- * @returns A promise that resolves to a {@linkcode Result} object containing either a {@linkcode Request} or an error. ++ * @returns A promise that resolves to a {@linkcode ScrapboxResponse} object. + */ + export const robustFetch: RobustFetch = async (input, init) => { + const request = new Request(input, init); + try { +- return createOk(await globalThis.fetch(request)); ++ const response = await globalThis.fetch(request); ++ return ScrapboxResponse.from(response); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === "AbortError") { +- return createErr({ ++ return ScrapboxResponse.error({ + name: "AbortError", + message: e.message, + request, +- }); ++ }, { status: 499 }); // Use 499 for client closed request + } + if (e instanceof TypeError) { +- return createErr({ ++ return ScrapboxResponse.error({ + name: "NetworkError", + message: e.message, + request, +- }); ++ }, { status: 0 }); // Use 0 for network errors + } + throw e; + } +diff --git a/rest/search.ts b/rest/search.ts +index 03c4ffc..2b890d1 100644 +--- a/rest/search.ts ++++ b/rest/search.ts +@@ -1,10 +1,3 @@ +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; + import type { + NoQueryError, + NotFoundError, +@@ -15,7 +8,7 @@ import type { + } from "@cosense/types/rest"; + import { cookie } from "./auth.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; + import type { FetchError } from "./mod.ts"; + +@@ -36,7 +29,7 @@ export const searchForPages = async ( + query: string, + project: string, + init?: BaseOptions, +-): Promise> => { ++): Promise> => { + const { sid, hostName, fetch } = setDefaults(init ?? {}); + + const req = new Request( +@@ -47,21 +40,16 @@ export const searchForPages = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; +- +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- (await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotMemberError", +- "NoQueryError", +- ])) ?? error, +- ), +- (res) => res.json() as Promise, +- ); ++ const response = ScrapboxResponse.from(res); ++ ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotMemberError", ++ "NoQueryError", ++ ]); ++ ++ return response; + }; + + export type SearchForJoinedProjectsError = +@@ -78,7 +66,7 @@ export const searchForJoinedProjects = async ( + query: string, + init?: BaseOptions, + ): Promise< +- Result< ++ ScrapboxResponse< + ProjectSearchResult, + SearchForJoinedProjectsError | FetchError + > +@@ -93,19 +81,14 @@ export const searchForJoinedProjects = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; +- +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- (await parseHTTPError(error, [ +- "NotLoggedInError", +- "NoQueryError", +- ])) ?? error, +- ), +- (res) => res.json() as Promise, +- ); ++ const response = ScrapboxResponse.from(res); ++ ++ await parseHTTPError(response, [ ++ "NotLoggedInError", ++ "NoQueryError", ++ ]); ++ ++ return response; + }; + + export type SearchForWatchListError = SearchForJoinedProjectsError; +@@ -125,7 +108,7 @@ export const searchForWatchList = async ( + projectIds: string[], + init?: BaseOptions, + ): Promise< +- Result< ++ ScrapboxResponse< + ProjectSearchResult, + SearchForWatchListError | FetchError + > +@@ -143,17 +126,12 @@ export const searchForWatchList = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; +- +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- (await parseHTTPError(error, [ +- "NotLoggedInError", +- "NoQueryError", +- ])) ?? error, +- ), +- (res) => res.json() as Promise, +- ); ++ const response = ScrapboxResponse.from(res); ++ ++ await parseHTTPError(response, [ ++ "NotLoggedInError", ++ "NoQueryError", ++ ]); ++ ++ return response; + }; +diff --git a/rest/snapshot.ts b/rest/snapshot.ts +index 8fdc70c..ff07536 100644 +--- a/rest/snapshot.ts ++++ b/rest/snapshot.ts +@@ -9,14 +9,7 @@ import type { + import { cookie } from "./auth.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import type { FetchError } from "./mod.ts"; + + /** 不正な`timestampId`を渡されたときに発生するエラー */ +@@ -40,7 +33,7 @@ export const getSnapshot = async ( + pageId: string, + timestampId: string, + options?: BaseOptions, +-): Promise> => { ++): Promise> => { + const { sid, hostName, fetch } = setDefaults(options ?? {}); + + const req = new Request( +@@ -49,25 +42,22 @@ export const getSnapshot = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; ++ const response = ScrapboxResponse.from(res); + +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- error.response.status === 422 +- ? { +- name: "InvalidPageSnapshotIdError", +- message: await error.response.text(), +- } +- : (await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotMemberError", +- ])) ?? error, +- ), +- (res) => res.json() as Promise, +- ); ++ if (response.status === 422) { ++ return ScrapboxResponse.error({ ++ name: "InvalidPageSnapshotIdError", ++ message: await response.text(), ++ }); ++ } ++ ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ return response; + }; + + export type SnapshotTimestampIdsError = +@@ -90,7 +80,7 @@ export const getTimestampIds = async ( + pageId: string, + options?: BaseOptions, + ): Promise< +- Result ++ ScrapboxResponse + > => { + const { sid, hostName, fetch } = setDefaults(options ?? {}); + +@@ -100,18 +90,13 @@ export const getTimestampIds = async ( + ); + + const res = await fetch(req); +- if (isErr(res)) return res; ++ const response = ScrapboxResponse.from(res); + +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- (await parseHTTPError(error, [ +- "NotFoundError", +- "NotLoggedInError", +- "NotMemberError", +- ])) ?? error, +- ), +- (res) => res.json() as Promise, +- ); ++ await parseHTTPError(response, [ ++ "NotFoundError", ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ return response; + }; +diff --git a/rest/table.ts b/rest/table.ts +index b013265..5cb8688 100644 +--- a/rest/table.ts ++++ b/rest/table.ts +@@ -6,14 +6,7 @@ import type { + import { cookie } from "./auth.ts"; + import { encodeTitleURI } from "../title.ts"; + import { type BaseOptions, setDefaults } from "./options.ts"; +-import { +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + import { parseHTTPError } from "./parseHTTPError.ts"; + import type { FetchError } from "./mod.ts"; + +@@ -34,24 +27,29 @@ const getTable_toRequest: GetTable["toRequest"] = ( + ); + }; + +-const getTable_fromResponse: GetTable["fromResponse"] = async (res) => +- mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(res), +- async (error) => +- error.response.status === 404 +- ? { +- // responseが空文字の時があるので、自前で組み立てる +- name: "NotFoundError", +- message: "Table not found.", +- } +- : (await parseHTTPError(error, [ +- "NotLoggedInError", +- "NotMemberError", +- ])) ?? error, +- ), +- (res) => res.text(), +- ); ++const getTable_fromResponse: GetTable["fromResponse"] = async (res) => { ++ const response = ScrapboxResponse.from(res); ++ ++ if (response.status === 404) { ++ // Build our own error message since the response might be empty ++ return ScrapboxResponse.error({ ++ name: "NotFoundError", ++ message: "Table not found.", ++ }); ++ } ++ ++ await parseHTTPError(response, [ ++ "NotLoggedInError", ++ "NotMemberError", ++ ]); ++ ++ if (response.ok) { ++ const text = await response.text(); ++ return ScrapboxResponse.ok(text); ++ } ++ ++ return response; ++}; + + export type TableError = + | NotFoundError +@@ -80,14 +78,14 @@ export interface GetTable { + * @param res 応答 + * @return ページのJSONデータ + */ +- fromResponse: (res: Response) => Promise>; ++ fromResponse: (res: Response) => Promise>; + + ( + project: string, + title: string, + filename: string, + options?: BaseOptions, +- ): Promise>; ++ ): Promise>; + } + + /** 指定したテーブルをCSV形式で得る +@@ -107,8 +105,7 @@ export const getTable: GetTable = /* @__PURE__ */ (() => { + const { fetch } = setDefaults(options ?? {}); + const req = getTable_toRequest(project, title, filename, options); + const res = await fetch(req); +- if (isErr(res)) return res; +- return await getTable_fromResponse(unwrapOk(res)); ++ return getTable_fromResponse(res); + }; + + fn.toRequest = getTable_toRequest; +diff --git a/rest/uploadToGCS.ts b/rest/uploadToGCS.ts +index 00d6f0a..2861ac3 100644 +--- a/rest/uploadToGCS.ts ++++ b/rest/uploadToGCS.ts +@@ -7,19 +7,8 @@ import { + import type { ErrorLike, NotFoundError } from "@cosense/types/rest"; + import { md5 } from "@takker/md5"; + import { encodeHex } from "@std/encoding/hex"; +-import { +- createOk, +- isErr, +- mapAsyncForResult, +- mapErrAsyncForResult, +- mapForResult, +- orElseAsyncForResult, +- type Result, +- unwrapOk, +-} from "option-t/plain_result"; +-import { toResultOkFromMaybe } from "option-t/maybe"; + import type { FetchError } from "./robustFetch.ts"; +-import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; ++import { ScrapboxResponse } from "./response.ts"; + + /** uploadしたファイルのメタデータ */ + export interface GCSFile { +@@ -46,14 +35,14 @@ export const uploadToGCS = async ( + file: File, + projectId: string, + options?: ExtendedOptions, +-): Promise> => { ++): Promise> => { + const md5Hash = `${encodeHex(md5(await file.arrayBuffer()))}`; + const res = await uploadRequest(file, projectId, md5Hash, options); +- if (isErr(res)) return res; +- const fileOrRequest = unwrapOk(res); +- if ("embedUrl" in fileOrRequest) return createOk(fileOrRequest); ++ if (!res.ok) return res; ++ const fileOrRequest = res.data; ++ if ("embedUrl" in fileOrRequest) return ScrapboxResponse.ok(fileOrRequest); + const result = await upload(fileOrRequest.signedUrl, file, options); +- if (isErr(result)) return result; ++ if (!result.ok) return result; + return verify(projectId, fileOrRequest.fileId, md5Hash, options); + }; + +@@ -83,7 +72,7 @@ const uploadRequest = async ( + md5: string, + init?: ExtendedOptions, + ): Promise< +- Result ++ ScrapboxResponse + > => { + const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); + const body = { +@@ -92,11 +81,10 @@ const uploadRequest = async ( + contentType: file.type, + name: file.name, + }; +- const csrfResult = await orElseAsyncForResult( +- toResultOkFromMaybe(csrf), +- () => getCSRFToken(init), +- ); +- if (isErr(csrfResult)) return csrfResult; ++ ++ const csrfToken = csrf ?? await getCSRFToken(init); ++ if (!csrfToken.ok) return csrfToken; ++ + const req = new Request( + `https://${hostName}/api/gcs/${projectId}/upload-request`, + { +@@ -104,27 +92,24 @@ const uploadRequest = async ( + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json;charset=utf-8", +- "X-CSRF-TOKEN": unwrapOk(csrfResult), ++ "X-CSRF-TOKEN": csrfToken.data, + ...(sid ? { Cookie: cookie(sid) } : {}), + }, + }, + ); ++ + const res = await fetch(req); +- if (isErr(res)) return res; +- +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- error.response.status === 402 +- ? { +- name: "FileCapacityError", +- message: (await error.response.json()).message, +- } as FileCapacityError +- : error, +- ), +- (res) => res.json(), +- ); ++ const response = ScrapboxResponse.from(res); ++ ++ if (response.status === 402) { ++ const json = await response.json(); ++ return ScrapboxResponse.error({ ++ name: "FileCapacityError", ++ message: json.message, ++ } as FileCapacityError); ++ } ++ ++ return response; + }; + + /** Google Cloud Storage XML APIのerror +@@ -140,7 +125,7 @@ const upload = async ( + signedUrl: string, + file: File, + init?: BaseOptions, +-): Promise> => { ++): Promise> => { + const { sid, fetch } = setDefaults(init ?? {}); + const res = await fetch( + signedUrl, +@@ -153,21 +138,17 @@ const upload = async ( + }, + }, + ); +- if (isErr(res)) return res; +- +- return mapForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- error.response.headers.get("Content-Type")?.includes?.("/xml") +- ? { +- name: "GCSError", +- message: await error.response.text(), +- } as GCSError +- : error, +- ), +- () => undefined, +- ); ++ ++ const response = ScrapboxResponse.from(res); ++ ++ if (!response.ok && response.headers.get("Content-Type")?.includes?.("/xml")) { ++ return ScrapboxResponse.error({ ++ name: "GCSError", ++ message: await response.text(), ++ } as GCSError); ++ } ++ ++ return response.ok ? ScrapboxResponse.ok(undefined) : response; + }; + + /** uploadしたファイルの整合性を確認する */ +@@ -176,13 +157,12 @@ const verify = async ( + fileId: string, + md5: string, + init?: ExtendedOptions, +-): Promise> => { ++): Promise> => { + const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); +- const csrfResult = await orElseAsyncForResult( +- toResultOkFromMaybe(csrf), +- () => getCSRFToken(init), +- ); +- if (isErr(csrfResult)) return csrfResult; ++ ++ const csrfToken = csrf ?? await getCSRFToken(init); ++ if (!csrfToken.ok) return csrfToken; ++ + const req = new Request( + `https://${hostName}/api/gcs/${projectId}/verify`, + { +@@ -190,26 +170,22 @@ const verify = async ( + body: JSON.stringify({ md5, fileId }), + headers: { + "Content-Type": "application/json;charset=utf-8", +- "X-CSRF-TOKEN": unwrapOk(csrfResult), ++ "X-CSRF-TOKEN": csrfToken.data, + ...(sid ? { Cookie: cookie(sid) } : {}), + }, + }, + ); + + const res = await fetch(req); +- if (isErr(res)) return res; +- +- return mapAsyncForResult( +- await mapErrAsyncForResult( +- responseIntoResult(unwrapOk(res)), +- async (error) => +- error.response.status === 404 +- ? { +- name: "NotFoundError", +- message: (await error.response.json()).message, +- } as NotFoundError +- : error, +- ), +- (res) => res.json(), +- ); ++ const response = ScrapboxResponse.from(res); ++ ++ if (response.status === 404) { ++ const json = await response.json(); ++ return ScrapboxResponse.error({ ++ name: "NotFoundError", ++ message: json.message, ++ } as NotFoundError); ++ } ++ ++ return response; + }; +-- +2.34.1 + diff --git a/rest/auth.ts b/rest/auth.ts index 65df74c..a9ed092 100644 --- a/rest/auth.ts +++ b/rest/auth.ts @@ -1,6 +1,7 @@ -import { createOk, mapForResult, type Result } from "option-t/plain_result"; import { getProfile } from "./profile.ts"; -import type { HTTPError } from "./responseIntoResult.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { createErrorResponse, createSuccessResponse } from "./utils.ts"; +import type { HTTPError } from "./errors.ts"; import type { AbortError, NetworkError } from "./robustFetch.ts"; import type { ExtendedOptions } from "./options.ts"; @@ -16,11 +17,29 @@ export const cookie = (sid: string): string => `connect.sid=${sid}`; */ export const getCSRFToken = async ( init?: ExtendedOptions, -): Promise> => { +): Promise< + TargetedResponse< + 200 | 400 | 404 | 0 | 499, + { csrfToken: string } | NetworkError | AbortError | HTTPError + > +> => { // deno-lint-ignore no-explicit-any const csrf = init?.csrf ?? (globalThis as any)._csrf; - return csrf ? createOk(csrf) : mapForResult( - await getProfile(init), - (user) => user.csrfToken, - ); + if (csrf) { + return createSuccessResponse({ csrfToken: csrf }); + } + + const profile = await getProfile(init); + if (!profile.ok) return profile; + + const data = await profile.json(); + if (!("csrfToken" in data)) { + return createErrorResponse(400, { + name: "HTTPError", + message: "Invalid response format", + status: 400, + statusText: "Bad Request", + }); + } + return createSuccessResponse({ csrfToken: data.csrfToken }); }; diff --git a/rest/errors.ts b/rest/errors.ts new file mode 100644 index 0000000..48a6709 --- /dev/null +++ b/rest/errors.ts @@ -0,0 +1,31 @@ +import type { + BadRequestError, + InvalidURLError, + NoQueryError, + NotFoundError, + NotLoggedInError, + NotMemberError, + NotPrivilegeError, + SessionError, +} from "@cosense/types/rest"; +import type { StatusCode } from "jsr:@std/http"; + +/** + * Represents an HTTP error with status code and message + */ +export interface HTTPError { + status: StatusCode; + statusText: string; + message?: string; +} + +export type RESTError = + | BadRequestError + | NotFoundError + | NotLoggedInError + | NotMemberError + | SessionError + | InvalidURLError + | NoQueryError + | NotPrivilegeError + | HTTPError; diff --git a/rest/getCodeBlock.ts b/rest/getCodeBlock.ts index 42a25f4..9fa9244 100644 --- a/rest/getCodeBlock.ts +++ b/rest/getCodeBlock.ts @@ -6,15 +6,13 @@ import type { import { cookie } from "./auth.ts"; import { encodeTitleURI } from "../title.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + createErrorResponse, + createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./mod.ts"; const getCodeBlock_toRequest: GetCodeBlock["toRequest"] = ( @@ -33,21 +31,31 @@ const getCodeBlock_toRequest: GetCodeBlock["toRequest"] = ( ); }; -const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => - mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(res), - async (res) => - res.response.status === 404 && - res.response.headers.get("Content-Type")?.includes?.("text/plain") - ? { name: "NotFoundError", message: "Code block is not found" } - : (await parseHTTPError(res, [ - "NotLoggedInError", - "NotMemberError", - ])) ?? res, - ), - (res) => res.text(), - ); +const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => { + const response = createTargetedResponse<200 | 400 | 404, CodeBlockError>(res); + + if ( + response.status === 404 && + response.headers.get("Content-Type")?.includes?.("text/plain") + ) { + return createErrorResponse(404, { + name: "NotFoundError", + message: "Code block is not found", + }); + } + + await parseHTTPError(response, [ + "NotLoggedInError", + "NotMemberError", + ]); + + if (response.ok) { + const text = await response.text(); + return createSuccessResponse(text); + } + + return response; +}; export interface GetCodeBlock { /** /api/code/:project/:title/:filename の要求を組み立てる @@ -70,14 +78,18 @@ export interface GetCodeBlock { * @param res 応答 * @return コード */ - fromResponse: (res: Response) => Promise>; + fromResponse: ( + res: Response, + ) => Promise>; ( project: string, title: string, filename: string, options?: BaseOptions, - ): Promise>; + ): Promise< + TargetedResponse<200 | 400 | 404, string | CodeBlockError | FetchError> + >; } export type CodeBlockError = | NotFoundError @@ -101,7 +113,7 @@ export const getCodeBlock: GetCodeBlock = /* @__PURE__ */ (() => { ) => { const req = getCodeBlock_toRequest(project, title, filename, options); const res = await setDefaults(options ?? {}).fetch(req); - return isErr(res) ? res : getCodeBlock_fromResponse(unwrapOk(res)); + return getCodeBlock_fromResponse(res); }; fn.toRequest = getCodeBlock_toRequest; diff --git a/rest/getGyazoToken.ts b/rest/getGyazoToken.ts index b9bbac2..771f4f5 100644 --- a/rest/getGyazoToken.ts +++ b/rest/getGyazoToken.ts @@ -1,15 +1,13 @@ -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { NotLoggedInError } from "@cosense/types/rest"; import { cookie } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + type createErrorResponse as _createErrorResponse, + createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./mod.ts"; export interface GetGyazoTokenOptions extends BaseOptions { @@ -29,7 +27,12 @@ export type GyazoTokenError = NotLoggedInError | HTTPError; */ export const getGyazoToken = async ( init?: GetGyazoTokenOptions, -): Promise> => { +): Promise< + TargetedResponse< + 200 | 400 | 404, + string | undefined | GyazoTokenError | FetchError + > +> => { const { fetch, sid, hostName, gyazoTeamsName } = setDefaults(init ?? {}); const req = new Request( `https://${hostName}/api/login/gyazo/oauth-upload/token${ @@ -39,14 +42,16 @@ export const getGyazoToken = async ( ); const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - (await parseHTTPError(error, ["NotLoggedInError"])) ?? error, - ), - (res) => res.json().then((json) => json.token as string | undefined), + const response = createTargetedResponse<200 | 400 | 404, GyazoTokenError>( + res, ); + + await parseHTTPError(response, ["NotLoggedInError"]); + + if (response.ok) { + const json = await response.json(); + return createSuccessResponse(json.token as string | undefined); + } + + return response; }; diff --git a/rest/getTweetInfo.ts b/rest/getTweetInfo.ts index 48408d9..069083e 100644 --- a/rest/getTweetInfo.ts +++ b/rest/getTweetInfo.ts @@ -1,10 +1,3 @@ -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { BadRequestError, InvalidURLError, @@ -13,8 +6,13 @@ import type { } from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import { type ExtendedOptions, setDefaults } from "./options.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + createErrorResponse, + type createSuccessResponse as _createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./mod.ts"; export type TweetInfoError = @@ -32,11 +30,16 @@ export type TweetInfoError = export const getTweetInfo = async ( url: string | URL, init?: ExtendedOptions, -): Promise> => { +): Promise< + TargetedResponse< + 200 | 400 | 404 | 422, + TweetInfo | TweetInfoError | FetchError + > +> => { const { sid, hostName, fetch } = setDefaults(init ?? {}); - const csrfResult = await getCSRFToken(init); - if (isErr(csrfResult)) return csrfResult; + const csrfToken = await getCSRFToken(init); + if (!csrfToken.ok) return csrfToken; const req = new Request( `https://${hostName}/api/embed-text/twitter?url=${ @@ -46,7 +49,7 @@ export const getTweetInfo = async ( method: "POST", headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": unwrapOk(csrfResult), + "X-CSRF-TOKEN": csrfToken.data, ...(sid ? { Cookie: cookie(sid) } : {}), }, body: JSON.stringify({ timeout: 3000 }), @@ -54,25 +57,23 @@ export const getTweetInfo = async ( ); const res = await fetch(req); - if (isErr(res)) return res; + const response = createTargetedResponse< + 200 | 400 | 404 | 422, + TweetInfoError + >(res); - return mapErrAsyncForResult( - await mapAsyncForResult( - responseIntoResult(unwrapOk(res)), - (res) => res.json() as Promise, - ), - async (res) => { - if (res.response.status === 422) { - return { - name: "InvalidURLError", - message: (await res.response.json()).message as string, - }; - } - const parsed = await parseHTTPError(res, [ - "SessionError", - "BadRequestError", - ]); - return parsed ?? res; - }, - ); + if (response.status === 422) { + const json = await response.json(); + return createErrorResponse(422, { + name: "InvalidURLError", + message: json.message as string, + }); + } + + await parseHTTPError(response, [ + "SessionError", + "BadRequestError", + ]); + + return response; }; diff --git a/rest/getWebPageTitle.ts b/rest/getWebPageTitle.ts index c523aff..db00a21 100644 --- a/rest/getWebPageTitle.ts +++ b/rest/getWebPageTitle.ts @@ -1,10 +1,3 @@ -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { BadRequestError, InvalidURLError, @@ -12,8 +5,13 @@ import type { } from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import { type ExtendedOptions, setDefaults } from "./options.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + type createErrorResponse as _createErrorResponse, + createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./mod.ts"; export type WebPageTitleError = @@ -31,11 +29,13 @@ export type WebPageTitleError = export const getWebPageTitle = async ( url: string | URL, init?: ExtendedOptions, -): Promise> => { +): Promise< + TargetedResponse<200 | 400 | 404, string | WebPageTitleError | FetchError> +> => { const { sid, hostName, fetch } = setDefaults(init ?? {}); - const csrfResult = await getCSRFToken(init); - if (isErr(csrfResult)) return csrfResult; + const csrfToken = await getCSRFToken(init); + if (!csrfToken.ok) return csrfToken; const req = new Request( `https://${hostName}/api/embed-text/url?url=${ @@ -45,7 +45,7 @@ export const getWebPageTitle = async ( method: "POST", headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": unwrapOk(csrfResult), + "X-CSRF-TOKEN": csrfToken.data, ...(sid ? { Cookie: cookie(sid) } : {}), }, body: JSON.stringify({ timeout: 3000 }), @@ -53,21 +53,20 @@ export const getWebPageTitle = async ( ); const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - (await parseHTTPError(error, [ - "SessionError", - "BadRequestError", - "InvalidURLError", - ])) ?? error, - ), - async (res) => { - const { title } = (await res.json()) as { title: string }; - return title; - }, + const response = createTargetedResponse<200 | 400 | 404, WebPageTitleError>( + res, ); + + await parseHTTPError(response, [ + "SessionError", + "BadRequestError", + "InvalidURLError", + ]); + + if (response.ok) { + const { title } = await response.json() as { title: string }; + return createSuccessResponse(title); + } + + return response; }; diff --git a/rest/json_compatible.ts b/rest/json_compatible.ts new file mode 100644 index 0000000..f774be9 --- /dev/null +++ b/rest/json_compatible.ts @@ -0,0 +1,123 @@ +import type { JsonValue } from "jsr:/@std/json@^1.0.1/types"; +import type { IsAny } from "jsr:/@std/testing@^1.0.8/types"; +export type { IsAny, JsonValue }; + +/** + * Check if a property {@linkcode K} is optional in {@linkcode T}. + * + * ```ts + * import type { Assert } from "jsr:/@std/testing@^1.0.8/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 "jsr:/@std/json@^1.0.1/types"; + * import { assertType } from "jsr:/@std/testing@^1.0.8/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/rest/link.ts b/rest/link.ts index 49cbbd3..1b6021e 100644 --- a/rest/link.ts +++ b/rest/link.ts @@ -1,11 +1,3 @@ -import { - createOk, - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { ErrorLike, NotFoundError, @@ -14,7 +6,11 @@ import type { } from "@cosense/types/rest"; import { cookie } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { TargetedResponse as _TargetedResponse } from "./targeted_response.ts"; +import type { + createErrorResponse as _createErrorResponse, + createSuccessResponse as _createSuccessResponse, +} from "./utils.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; import type { FetchError } from "./mod.ts"; @@ -49,7 +45,7 @@ export interface GetLinks { ( project: string, options?: GetLinksOptions, - ): Promise>; + ): Promise>; /** Create a request to `GET /api/pages/:project/search/titles` * @@ -66,7 +62,7 @@ export interface GetLinks { */ fromResponse: ( response: Response, - ) => Promise>; + ) => Promise>; } const getLinks_toRequest: GetLinks["toRequest"] = (project, options) => { @@ -80,27 +76,27 @@ const getLinks_toRequest: GetLinks["toRequest"] = (project, options) => { ); }; -const getLinks_fromResponse: GetLinks["fromResponse"] = async (response) => - mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(response), - async (error) => - error.response.status === 422 - ? { - name: "InvalidFollowingIdError", - message: await error.response.text(), - } as InvalidFollowingIdError - : (await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - ])) ?? error, - ), - (res) => - res.json().then((pages: SearchedTitle[]) => ({ - pages, - followingId: res.headers.get("X-following-id") ?? "", - })), - ); +const getLinks_fromResponse: GetLinks["fromResponse"] = async (response) => { + const res = ScrapboxResponse.from(response); + + if (res.status === 422) { + return ScrapboxResponse.error({ + name: "InvalidFollowingIdError", + message: await response.text(), + } as InvalidFollowingIdError); + } + + await parseHTTPError(res, [ + "NotFoundError", + "NotLoggedInError", + ]); + + const pages = await res.json() as SearchedTitle[]; + return ScrapboxResponse.ok({ + pages, + followingId: response.headers.get("X-following-id") ?? "", + }); +}; /** 指定したprojectのリンクデータを取得する * @@ -108,11 +104,10 @@ const getLinks_fromResponse: GetLinks["fromResponse"] = async (response) => */ export const getLinks: GetLinks = /* @__PURE__ */ (() => { const fn: GetLinks = async (project, options) => { - const res = await setDefaults(options ?? {}).fetch( + const response = await setDefaults(options ?? {}).fetch( getLinks_toRequest(project, options), ); - if (isErr(res)) return res; - return getLinks_fromResponse(unwrapOk(res)); + return getLinks_fromResponse(response); }; fn.toRequest = getLinks_toRequest; @@ -131,21 +126,20 @@ export async function* readLinksBulk( project: string, options?: BaseOptions, ): AsyncGenerator< - Result, + ScrapboxResponse, void, unknown > { let followingId: string | undefined; do { const result = await getLinks(project, { followingId, ...options }); - if (isErr(result)) { + if (!result.ok) { yield result; return; } - const res = unwrapOk(result); - yield createOk(res.pages); - followingId = res.followingId; + yield ScrapboxResponse.ok(result.data.pages); + followingId = result.data.followingId; } while (followingId); } @@ -158,17 +152,17 @@ export async function* readLinks( project: string, options?: BaseOptions, ): AsyncGenerator< - Result, + ScrapboxResponse, void, unknown > { for await (const result of readLinksBulk(project, options)) { - if (isErr(result)) { + if (!result.ok) { yield result; return; } - for (const page of unwrapOk(result)) { - yield createOk(page); + for (const page of result.data) { + yield ScrapboxResponse.ok(page); } } } diff --git a/rest/page-data.ts b/rest/page-data.ts index 13d43e2..e8b8f10 100644 --- a/rest/page-data.ts +++ b/rest/page-data.ts @@ -1,11 +1,3 @@ -import { - createOk, - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { ExportedData, ImportedData, @@ -15,7 +7,11 @@ import type { } from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { TargetedResponse as _TargetedResponse } from "./targeted_response.ts"; +import type { + createErrorResponse as _createErrorResponse, + createSuccessResponse as _createSuccessResponse, +} from "./utils.ts"; import { type BaseOptions, type ExtendedOptions, @@ -35,9 +31,11 @@ export const importPages = async ( data: ImportedData, init?: ExtendedOptions, ): Promise< - Result + ScrapboxResponse > => { - if (data.pages.length === 0) return createOk("No pages to import."); + if (data.pages.length === 0) { + return ScrapboxResponse.ok("No pages to import."); + } const { sid, hostName, fetch } = setDefaults(init ?? {}); const formData = new FormData(); @@ -49,8 +47,8 @@ export const importPages = async ( ); formData.append("name", "undefined"); - const csrfResult = await getCSRFToken(init); - if (isErr(csrfResult)) return csrfResult; + const csrfToken = await getCSRFToken(init); + if (!csrfToken.ok) return csrfToken; const req = new Request( `https://${hostName}/api/page-data/import/${project}.json`, @@ -59,19 +57,21 @@ export const importPages = async ( headers: { ...(sid ? { Cookie: cookie(sid) } : {}), Accept: "application/json, text/plain, */*", - "X-CSRF-TOKEN": unwrapOk(csrfResult), + "X-CSRF-TOKEN": csrfToken.data, }, body: formData, }, ); const res = await fetch(req); - if (isErr(res)) return res; + const response = ScrapboxResponse.from(res); - return mapAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (res) => (await res.json()).message as string, - ); + if (response.ok) { + const json = await response.json(); + return ScrapboxResponse.ok(json.message as string); + } + + return response; }; export type ExportPagesError = @@ -93,7 +93,7 @@ export const exportPages = async ( project: string, init: ExportInit, ): Promise< - Result, ExportPagesError | FetchError> + ScrapboxResponse, ExportPagesError | FetchError> > => { const { sid, hostName, fetch, metadata } = setDefaults(init ?? {}); @@ -102,18 +102,16 @@ export const exportPages = async ( sid ? { headers: { Cookie: cookie(sid) } } : undefined, ); const res = await fetch(req); - if (isErr(res)) return res; + const response = ScrapboxResponse.from< + ExportedData, + ExportPagesError + >(res); - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - (await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotPrivilegeError", - ])) ?? error, - ), - (res) => res.json() as Promise>, - ); + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotPrivilegeError", + ]); + + return response; }; diff --git a/rest/pages.ts b/rest/pages.ts index bc86592..b79bb99 100644 --- a/rest/pages.ts +++ b/rest/pages.ts @@ -10,15 +10,14 @@ import { cookie } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; import { encodeTitleURI } from "../title.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; import { - andThenAsyncForResult, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, -} from "option-t/plain_result"; -import { unwrapOrForMaybe } from "option-t/maybe"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; -import type { FetchError } from "./robustFetch.ts"; + createErrorResponse, + createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; +import type { HTTPError } from "./errors.ts"; +import type { AbortError, NetworkError } from "./robustFetch.ts"; /** Options for `getPage()` */ export interface GetPageOption extends BaseOptions { @@ -53,39 +52,25 @@ const getPage_toRequest: GetPage["toRequest"] = ( ); }; -const getPage_fromResponse: GetPage["fromResponse"] = async (res) => - mapErrAsyncForResult( - await mapAsyncForResult( - responseIntoResult(res), - (res) => res.json() as Promise, - ), - async ( - error, - ) => { - if (error.response.status === 414) { - return { - name: "TooLongURIError", - message: "project ids may be too much.", - }; - } - - return unwrapOrForMaybe( - await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotMemberError", - ]), - error, - ); - }, - ); +const getPage_fromResponse: GetPage["fromResponse"] = async (res) => { + const response = createTargetedResponse<200 | 400 | 404, Page>(res); + + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + "TooLongURIError", + ]); + + return response; +}; export interface GetPage { - /** /api/pages/:project/:title の要求を組み立てる + /** Build request for /api/pages/:project/:title * - * @param project 取得したいページのproject名 - * @param title 取得したいページのtitle 大文字小文字は問わない - * @param options オプション + * @param project Project name to get page from + * @param title Page title (case insensitive) + * @param options Additional options * @return request */ toRequest: ( @@ -94,18 +79,25 @@ export interface GetPage { options?: GetPageOption, ) => Request; - /** 帰ってきた応答からページのJSONデータを取得する + /** Get page JSON data from response * - * @param res 応答 - * @return ページのJSONデータ + * @param res Response object + * @return Page JSON data */ - fromResponse: (res: Response) => Promise>; + fromResponse: ( + res: Response, + ) => Promise>; ( project: string, title: string, options?: GetPageOption, - ): Promise>; + ): Promise< + TargetedResponse< + 200 | 400 | 404 | 408 | 500, + Page | NetworkError | AbortError + > + >; } export type PageError = @@ -126,13 +118,12 @@ export const getPage: GetPage = /* @__PURE__ */ (() => { project, title, options, - ) => - andThenAsyncForResult( - await setDefaults(options ?? {}).fetch( - getPage_toRequest(project, title, options), - ), - (input) => getPage_fromResponse(input), + ) => { + const response = await setDefaults(options ?? {}).fetch( + getPage_toRequest(project, title, options), ); + return getPage_fromResponse(response); + }; fn.toRequest = getPage_toRequest; fn.fromResponse = getPage_fromResponse; @@ -168,10 +159,10 @@ export interface ListPagesOption extends BaseOptions { } export interface ListPages { - /** /api/pages/:project の要求を組み立てる + /** Build request for /api/pages/:project * - * @param project 取得したいページのproject名 - * @param options オプション + * @param project Project name to list pages from + * @param options Additional options * @return request */ toRequest: ( @@ -179,17 +170,24 @@ export interface ListPages { options?: ListPagesOption, ) => Request; - /** 帰ってきた応答からページのJSONデータを取得する + /** Get page list JSON data from response * - * @param res 応答 - * @return ページのJSONデータ + * @param res Response object + * @return Page list JSON data */ - fromResponse: (res: Response) => Promise>; + fromResponse: ( + res: Response, + ) => Promise>; ( project: string, options?: ListPagesOption, - ): Promise>; + ): Promise< + TargetedResponse< + 200 | 400 | 404 | 408 | 500, + PageList | NetworkError | AbortError + > + >; } export type ListPagesError = @@ -213,22 +211,17 @@ const listPages_toRequest: ListPages["toRequest"] = (project, options) => { ); }; -const listPages_fromResponse: ListPages["fromResponse"] = async (res) => - mapErrAsyncForResult( - await mapAsyncForResult( - responseIntoResult(res), - (res) => res.json() as Promise, - ), - async (error) => - unwrapOrForMaybe( - await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotMemberError", - ]), - error, - ), - ); +const listPages_fromResponse: ListPages["fromResponse"] = async (res) => { + const response = createTargetedResponse<200 | 400 | 404, PageList>(res); + + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ]); + + return response; +}; /** 指定したprojectのページを一覧する * @@ -239,13 +232,12 @@ export const listPages: ListPages = /* @__PURE__ */ (() => { const fn: ListPages = async ( project, options?, - ) => - andThenAsyncForResult( - await setDefaults(options ?? {})?.fetch( - listPages_toRequest(project, options), - ), - listPages_fromResponse, + ) => { + const response = await setDefaults(options ?? {})?.fetch( + listPages_toRequest(project, options), ); + return listPages_fromResponse(response); + }; fn.toRequest = listPages_toRequest; fn.fromResponse = listPages_fromResponse; diff --git a/rest/parseHTTPError.ts b/rest/parseHTTPError.ts index 003ed9b..cee0cf4 100644 --- a/rest/parseHTTPError.ts +++ b/rest/parseHTTPError.ts @@ -8,13 +8,12 @@ import type { NotPrivilegeError, SessionError, } from "@cosense/types/rest"; -import type { Maybe } from "option-t/maybe"; import { isArrayOf } from "@core/unknownutil/is/array-of"; import { isLiteralOneOf } from "@core/unknownutil/is/literal-one-of"; import { isRecord } from "@core/unknownutil/is/record"; import { isString } from "@core/unknownutil/is/string"; - -import type { HTTPError } from "./responseIntoResult.ts"; +import type { TargetedResponse as _TargetedResponse } from "./targeted_response.ts"; +import type { createErrorResponse as _createErrorResponse } from "./utils.ts"; export interface RESTfullAPIErrorMap { BadRequestError: BadRequestError; @@ -27,20 +26,22 @@ export interface RESTfullAPIErrorMap { NotPrivilegeError: NotPrivilegeError; } -/** 失敗した要求からエラー情報を取り出す */ +/** Extract error information from a failed request */ export const parseHTTPError = async < ErrorNames extends keyof RESTfullAPIErrorMap, + T = unknown, + E = unknown, >( - error: HTTPError, + response: Response, errorNames: ErrorNames[], -): Promise> => { - const res = error.response.clone(); +): Promise => { + const res = response.clone(); const isErrorNames = isLiteralOneOf(errorNames); try { const json: unknown = await res.json(); - if (!isRecord(json)) return; + if (!isRecord(json)) return undefined; if (res.status === 422) { - if (!isString(json.message)) return; + if (!isString(json.message)) return undefined; for ( const name of [ "NoQueryError", @@ -48,19 +49,23 @@ export const parseHTTPError = async < ] as (keyof RESTfullAPIErrorMap)[] ) { if (!(errorNames as string[]).includes(name)) continue; - return { + const error = { name, message: json.message, } as unknown as RESTfullAPIErrorMap[ErrorNames]; + Object.assign(response, { error }); + return error; } } - if (!isErrorNames(json.name)) return; - if (!isString(json.message)) return; + if (!isErrorNames(json.name)) return undefined; + if (!isString(json.message)) return undefined; if (json.name === "NotLoggedInError") { - if (!isRecord(json.detals)) return; - if (!isString(json.detals.project)) return; - if (!isArrayOf(isLoginStrategies)(json.detals.loginStrategies)) return; - return { + if (!isRecord(json.detals)) return undefined; + if (!isString(json.detals.project)) return undefined; + if (!isArrayOf(isLoginStrategies)(json.detals.loginStrategies)) { + return undefined; + } + const error = { name: json.name, message: json.message, details: { @@ -68,14 +73,18 @@ export const parseHTTPError = async < loginStrategies: json.detals.loginStrategies, }, } as unknown as RESTfullAPIErrorMap[ErrorNames]; + Object.assign(response, { error }); + return error; } - return { + const error = { name: json.name, message: json.message, } as unknown as RESTfullAPIErrorMap[ErrorNames]; + Object.assign(response, { error }); + return error; } catch (e: unknown) { - if (e instanceof SyntaxError) return; - // JSONのparse error以外はそのまま投げる + if (e instanceof SyntaxError) return undefined; + // Re-throw non-JSON parse errors throw e; } }; diff --git a/rest/profile.ts b/rest/profile.ts index 6e29310..eb73b55 100644 --- a/rest/profile.ts +++ b/rest/profile.ts @@ -1,39 +1,42 @@ -import { - isErr, - mapAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { GuestUser, MemberUser } from "@cosense/types/rest"; import { cookie } from "./auth.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + type createErrorResponse as _createErrorResponse, + type createSuccessResponse as _createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./robustFetch.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; export interface GetProfile { - /** /api/users/me の要求を組み立てる + /** Build request for /api/users/me * * @param init connect.sid etc. * @return request */ toRequest: (init?: BaseOptions) => Request; - /** get the user profile from the given response + /** Get user profile from response * - * @param res response - * @return user profile + * @param res Response object + * @return User profile */ fromResponse: ( res: Response, ) => Promise< - Result + TargetedResponse<200 | 400 | 404, MemberUser | GuestUser | ProfileError> >; (init?: BaseOptions): Promise< - Result + TargetedResponse< + 200 | 400 | 404 | 0 | 499, + MemberUser | GuestUser | ProfileError | FetchError + > >; } +import type { HTTPError } from "./errors.ts"; export type ProfileError = HTTPError; const getProfile_toRequest: GetProfile["toRequest"] = ( @@ -46,20 +49,19 @@ const getProfile_toRequest: GetProfile["toRequest"] = ( ); }; -const getProfile_fromResponse: GetProfile["fromResponse"] = (response) => - mapAsyncForResult( - responseIntoResult(response), - async (res) => (await res.json()) as MemberUser | GuestUser, - ); +const getProfile_fromResponse: GetProfile["fromResponse"] = (res) => { + return Promise.resolve(createTargetedResponse< + 200 | 400 | 404, + MemberUser | GuestUser | ProfileError + >(res)); +}; export const getProfile: GetProfile = /* @__PURE__ */ (() => { const fn: GetProfile = async (init) => { const { fetch, ...rest } = setDefaults(init ?? {}); - const resResult = await fetch(getProfile_toRequest(rest)); - return isErr(resResult) - ? resResult - : getProfile_fromResponse(unwrapOk(resResult)); + const response = await fetch(getProfile_toRequest(rest)); + return getProfile_fromResponse(response); }; fn.toRequest = getProfile_toRequest; diff --git a/rest/project.ts b/rest/project.ts index 24de85d..190bf05 100644 --- a/rest/project.ts +++ b/rest/project.ts @@ -1,10 +1,3 @@ -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { MemberProject, NotFoundError, @@ -16,14 +9,19 @@ import type { } from "@cosense/types/rest"; import { cookie } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + type createErrorResponse as _createErrorResponse, + type createSuccessResponse as _createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./robustFetch.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; export interface GetProject { - /** /api/project/:project の要求を組み立てる + /** Build request for /api/project/:project * - * @param project project name to get + * @param project Project name to get * @param init connect.sid etc. * @return request */ @@ -32,20 +30,28 @@ export interface GetProject { options?: BaseOptions, ) => Request; - /** 帰ってきた応答からprojectのJSONデータを取得する + /** Get project JSON data from response * - * @param res 応答 - * @return projectのJSONデータ + * @param res Response object + * @return Project JSON data */ fromResponse: ( res: Response, - ) => Promise>; + ) => Promise< + TargetedResponse< + 200 | 400 | 404, + MemberProject | NotMemberProject | ProjectError + > + >; ( project: string, options?: BaseOptions, ): Promise< - Result + TargetedResponse< + 200 | 400 | 404, + MemberProject | NotMemberProject | ProjectError | FetchError + > >; } @@ -54,6 +60,7 @@ export type ProjectError = | NotMemberError | NotLoggedInError | HTTPError; +import type { HTTPError } from "./errors.ts"; const getProject_toRequest: GetProject["toRequest"] = (project, init) => { const { sid, hostName } = setDefaults(init ?? {}); @@ -64,19 +71,20 @@ const getProject_toRequest: GetProject["toRequest"] = (project, init) => { ); }; -const getProject_fromResponse: GetProject["fromResponse"] = async (res) => - mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(res), - async (error) => - (await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotMemberError", - ])) ?? error, - ), - (res) => res.json() as Promise, - ); +const getProject_fromResponse: GetProject["fromResponse"] = async (res) => { + const response = createTargetedResponse< + 200 | 400 | 404, + MemberProject | NotMemberProject | ProjectError + >(res); + + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ]); + + return response; +}; /** get the project information * @@ -91,10 +99,8 @@ export const getProject: GetProject = /* @__PURE__ */ (() => { const { fetch } = setDefaults(init ?? {}); const req = getProject_toRequest(project, init); - const res = await fetch(req); - if (isErr(res)) return res; - - return getProject_fromResponse(unwrapOk(res)); + const response = await fetch(req); + return getProject_fromResponse(response); }; fn.toRequest = getProject_toRequest; @@ -104,9 +110,9 @@ export const getProject: GetProject = /* @__PURE__ */ (() => { })(); export interface ListProjects { - /** /api/project の要求を組み立てる + /** Build request for /api/project * - * @param projectIds project ids. This must have more than 1 id + * @param projectIds Project IDs (must have more than 1 ID) * @param init connect.sid etc. * @return request */ @@ -115,19 +121,26 @@ export interface ListProjects { init?: BaseOptions, ) => Request; - /** 帰ってきた応答からprojectのJSONデータを取得する + /** Get projects JSON data from response * - * @param res 応答 - * @return projectのJSONデータ + * @param res Response object + * @return Projects JSON data */ fromResponse: ( res: Response, - ) => Promise>; + ) => Promise< + TargetedResponse<200 | 400 | 404, ProjectResponse | ListProjectsError> + >; ( projectIds: ProjectId[], init?: BaseOptions, - ): Promise>; + ): Promise< + TargetedResponse< + 200 | 400 | 404, + ProjectResponse | ListProjectsError | FetchError + > + >; } export type ListProjectsError = NotLoggedInError | HTTPError; @@ -144,15 +157,16 @@ const ListProject_toRequest: ListProjects["toRequest"] = (projectIds, init) => { ); }; -const ListProject_fromResponse: ListProjects["fromResponse"] = async (res) => - mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(res), - async (error) => - (await parseHTTPError(error, ["NotLoggedInError"])) ?? error, - ), - (res) => res.json() as Promise, - ); +const ListProject_fromResponse: ListProjects["fromResponse"] = async (res) => { + const response = createTargetedResponse< + 200 | 400 | 404, + ProjectResponse | ListProjectsError + >(res); + + await parseHTTPError(response, ["NotLoggedInError"]); + + return response; +}; /** list the projects' information * @@ -166,10 +180,8 @@ export const listProjects: ListProjects = /* @__PURE__ */ (() => { ) => { const { fetch } = setDefaults(init ?? {}); - const res = await fetch(ListProject_toRequest(projectIds, init)); - if (isErr(res)) return res; - - return ListProject_fromResponse(unwrapOk(res)); + const response = await fetch(ListProject_toRequest(projectIds, init)); + return ListProject_fromResponse(response); }; fn.toRequest = ListProject_toRequest; diff --git a/rest/replaceLinks.ts b/rest/replaceLinks.ts index b89e43e..cdd9d78 100644 --- a/rest/replaceLinks.ts +++ b/rest/replaceLinks.ts @@ -1,10 +1,3 @@ -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { NotFoundError, NotLoggedInError, @@ -12,7 +5,12 @@ import type { } from "@cosense/types/rest"; import { cookie, getCSRFToken } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + type createErrorResponse as _createErrorResponse, + createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./robustFetch.ts"; import { type ExtendedOptions, setDefaults } from "./options.ts"; @@ -38,11 +36,13 @@ export const replaceLinks = async ( from: string, to: string, init?: ExtendedOptions, -): Promise> => { +): Promise< + TargetedResponse<200 | 400 | 404, number | ReplaceLinksError | FetchError> +> => { const { sid, hostName, fetch } = setDefaults(init ?? {}); - const csrfResult = await getCSRFToken(init); - if (isErr(csrfResult)) return csrfResult; + const csrfToken = await getCSRFToken(init); + if (!csrfToken.ok) return csrfToken; const req = new Request( `https://${hostName}/api/pages/${project}/replace/links`, @@ -50,30 +50,30 @@ export const replaceLinks = async ( method: "POST", headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": unwrapOk(csrfResult), + "X-CSRF-TOKEN": csrfToken.data, ...(sid ? { Cookie: cookie(sid) } : {}), }, body: JSON.stringify({ from, to }), }, ); - const resResult = await fetch(req); - if (isErr(resResult)) return resResult; + const res = await fetch(req); + const response = createTargetedResponse< + 200 | 400 | 404, + number | ReplaceLinksError + >(res); - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(resResult)), - async (error) => - (await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotMemberError", - ])) ?? error, - ), - async (res) => { - // messageには"2 pages have been successfully updated!"というような文字列が入っているはず - const { message } = (await res.json()) as { message: string }; - return parseInt(message.match(/\d+/)?.[0] ?? "0"); - }, - ); + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ]); + + if (response.ok) { + // The message contains text like "2 pages have been successfully updated!" + const { message } = await response.json() as { message: string }; + return createSuccessResponse(parseInt(message.match(/\d+/)?.[0] ?? "0")); + } + + return response; }; diff --git a/rest/robustFetch.ts b/rest/robustFetch.ts index e9b5305..e08a872 100644 --- a/rest/robustFetch.ts +++ b/rest/robustFetch.ts @@ -1,15 +1,27 @@ -import { createErr, createOk, type Result } from "option-t/plain_result"; +import type { StatusCode as _StatusCode } from "jsr:@std/http"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + createErrorResponse, + type createSuccessResponse as _createSuccessResponse, + type createTargetedResponse as _createTargetedResponse, +} from "./utils.ts"; -export interface NetworkError { +export interface NetworkError extends Response { name: "NetworkError"; message: string; request: Request; + ok: false; + status: 500; + statusText: "Network Error"; } -export interface AbortError { +export interface AbortError extends Response { name: "AbortError"; message: string; request: Request; + ok: false; + status: 408; + statusText: "Request Timeout"; } export type FetchError = NetworkError | AbortError; @@ -19,38 +31,62 @@ export type FetchError = NetworkError | AbortError; * * @param input - The resource URL or a {@linkcode Request} object. * @param init - An optional object containing request options. - * @returns A promise that resolves to a {@linkcode Result} object containing either a {@linkcode Request} or an error. + * @returns A promise that resolves to a {@linkcode TargetedResponse} object. */ export type RobustFetch = ( input: RequestInfo | URL, init?: RequestInit, -) => Promise>; +) => Promise< + | TargetedResponse<200, Response> + | TargetedResponse<400 | 404, Response> + | TargetedResponse<408, AbortError> + | TargetedResponse<500, NetworkError> +>; /** * A simple implementation of {@linkcode RobustFetch} that uses {@linkcode fetch}. * * @param input - The resource URL or a {@linkcode Request} object. * @param init - An optional object containing request options. - * @returns A promise that resolves to a {@linkcode Result} object containing either a {@linkcode Request} or an error. + * @returns A promise that resolves to a {@linkcode TargetedResponse} object. */ -export const robustFetch: RobustFetch = async (input, init) => { +export const robustFetch: RobustFetch = async (input, init): Promise< + | TargetedResponse<200, Response> + | TargetedResponse<400 | 404, Response> + | TargetedResponse<408, AbortError> + | TargetedResponse<500, NetworkError> +> => { const request = new Request(input, init); try { - return createOk(await globalThis.fetch(request)); + const response = await globalThis.fetch(request); + if (response.ok) { + return response as TargetedResponse<200, Response>; + } + return response as TargetedResponse<400 | 404, Response>; } catch (e: unknown) { if (e instanceof DOMException && e.name === "AbortError") { - return createErr({ - name: "AbortError", + const error = new Response(null, { + status: 408, + statusText: "Request Timeout", + }); + Object.assign(error, { + name: "AbortError" as const, message: e.message, - request, + request: request.clone(), }); + return createErrorResponse(408, error as AbortError); } if (e instanceof TypeError) { - return createErr({ - name: "NetworkError", + const error = new Response(null, { + status: 500, + statusText: "Network Error", + }); + Object.assign(error, { + name: "NetworkError" as const, message: e.message, - request, + request: request.clone(), }); + return createErrorResponse(500, error as NetworkError); } throw e; } diff --git a/rest/search.ts b/rest/search.ts index 03c4ffc..260e733 100644 --- a/rest/search.ts +++ b/rest/search.ts @@ -1,10 +1,3 @@ -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; import type { NoQueryError, NotFoundError, @@ -15,7 +8,12 @@ import type { } from "@cosense/types/rest"; import { cookie } from "./auth.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + type createErrorResponse as _createErrorResponse, + type createSuccessResponse as _createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; import type { FetchError } from "./mod.ts"; @@ -36,7 +34,12 @@ export const searchForPages = async ( query: string, project: string, init?: BaseOptions, -): Promise> => { +): Promise< + TargetedResponse< + 200 | 400 | 404, + SearchResult | SearchForPagesError | FetchError + > +> => { const { sid, hostName, fetch } = setDefaults(init ?? {}); const req = new Request( @@ -47,21 +50,19 @@ export const searchForPages = async ( ); const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - (await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotMemberError", - "NoQueryError", - ])) ?? error, - ), - (res) => res.json() as Promise, - ); + const response = createTargetedResponse< + 200 | 400 | 404, + SearchResult | SearchForPagesError + >(res); + + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + "NoQueryError", + ]); + + return response; }; export type SearchForJoinedProjectsError = @@ -78,9 +79,9 @@ export const searchForJoinedProjects = async ( query: string, init?: BaseOptions, ): Promise< - Result< - ProjectSearchResult, - SearchForJoinedProjectsError | FetchError + TargetedResponse< + 200 | 400 | 404, + ProjectSearchResult | SearchForJoinedProjectsError | FetchError > > => { const { sid, hostName, fetch } = setDefaults(init ?? {}); @@ -93,19 +94,17 @@ export const searchForJoinedProjects = async ( ); const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - (await parseHTTPError(error, [ - "NotLoggedInError", - "NoQueryError", - ])) ?? error, - ), - (res) => res.json() as Promise, - ); + const response = createTargetedResponse< + 200 | 400 | 404, + ProjectSearchResult | SearchForJoinedProjectsError + >(res); + + await parseHTTPError(response, [ + "NotLoggedInError", + "NoQueryError", + ]); + + return response; }; export type SearchForWatchListError = SearchForJoinedProjectsError; @@ -125,9 +124,9 @@ export const searchForWatchList = async ( projectIds: string[], init?: BaseOptions, ): Promise< - Result< - ProjectSearchResult, - SearchForWatchListError | FetchError + TargetedResponse< + 200 | 400 | 404, + ProjectSearchResult | SearchForWatchListError | FetchError > > => { const { sid, hostName, fetch } = setDefaults(init ?? {}); @@ -143,17 +142,15 @@ export const searchForWatchList = async ( ); const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - (await parseHTTPError(error, [ - "NotLoggedInError", - "NoQueryError", - ])) ?? error, - ), - (res) => res.json() as Promise, - ); + const response = createTargetedResponse< + 200 | 400 | 404, + ProjectSearchResult | SearchForWatchListError + >(res); + + await parseHTTPError(response, [ + "NotLoggedInError", + "NoQueryError", + ]); + + return response; }; diff --git a/rest/snapshot.ts b/rest/snapshot.ts index 8fdc70c..3b46046 100644 --- a/rest/snapshot.ts +++ b/rest/snapshot.ts @@ -9,15 +9,13 @@ import type { import { cookie } from "./auth.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import type { FetchError } from "./mod.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + createErrorResponse, + type createSuccessResponse as _createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; /** 不正な`timestampId`を渡されたときに発生するエラー */ export interface InvalidPageSnapshotIdError extends ErrorLike { @@ -40,7 +38,12 @@ export const getSnapshot = async ( pageId: string, timestampId: string, options?: BaseOptions, -): Promise> => { +): Promise< + TargetedResponse< + 200 | 400 | 404 | 422, + PageSnapshotResult | SnapshotError | FetchError + > +> => { const { sid, hostName, fetch } = setDefaults(options ?? {}); const req = new Request( @@ -49,25 +52,24 @@ export const getSnapshot = async ( ); const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - error.response.status === 422 - ? { - name: "InvalidPageSnapshotIdError", - message: await error.response.text(), - } - : (await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotMemberError", - ])) ?? error, - ), - (res) => res.json() as Promise, + const response = createTargetedResponse<200 | 400 | 404 | 422, SnapshotError>( + res, ); + + if (response.status === 422) { + return createErrorResponse(422, { + name: "InvalidPageSnapshotIdError", + message: await response.text(), + }); + } + + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ]); + + return response; }; export type SnapshotTimestampIdsError = @@ -90,7 +92,10 @@ export const getTimestampIds = async ( pageId: string, options?: BaseOptions, ): Promise< - Result + TargetedResponse< + 200 | 400 | 404, + PageSnapshotList | SnapshotTimestampIdsError | FetchError + > > => { const { sid, hostName, fetch } = setDefaults(options ?? {}); @@ -100,18 +105,16 @@ export const getTimestampIds = async ( ); const res = await fetch(req); - if (isErr(res)) return res; + const response = createTargetedResponse< + 200 | 400 | 404, + SnapshotTimestampIdsError + >(res); - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - (await parseHTTPError(error, [ - "NotFoundError", - "NotLoggedInError", - "NotMemberError", - ])) ?? error, - ), - (res) => res.json() as Promise, - ); + await parseHTTPError(response, [ + "NotFoundError", + "NotLoggedInError", + "NotMemberError", + ]); + + return response; }; diff --git a/rest/table.ts b/rest/table.ts index b013265..66b30be 100644 --- a/rest/table.ts +++ b/rest/table.ts @@ -6,15 +6,13 @@ import type { import { cookie } from "./auth.ts"; import { encodeTitleURI } from "../title.ts"; import { type BaseOptions, setDefaults } from "./options.ts"; -import { - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; import { parseHTTPError } from "./parseHTTPError.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + createErrorResponse, + createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; import type { FetchError } from "./mod.ts"; const getTable_toRequest: GetTable["toRequest"] = ( @@ -34,24 +32,29 @@ const getTable_toRequest: GetTable["toRequest"] = ( ); }; -const getTable_fromResponse: GetTable["fromResponse"] = async (res) => - mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(res), - async (error) => - error.response.status === 404 - ? { - // responseが空文字の時があるので、自前で組み立てる - name: "NotFoundError", - message: "Table not found.", - } - : (await parseHTTPError(error, [ - "NotLoggedInError", - "NotMemberError", - ])) ?? error, - ), - (res) => res.text(), - ); +const getTable_fromResponse: GetTable["fromResponse"] = async (res) => { + const response = createTargetedResponse<200 | 400 | 404, TableError>(res); + + if (response.status === 404) { + // Build our own error message since the response might be empty + return createErrorResponse(404, { + name: "NotFoundError", + message: "Table not found.", + }); + } + + await parseHTTPError(response, [ + "NotLoggedInError", + "NotMemberError", + ]); + + if (response.ok) { + const text = await response.text(); + return createSuccessResponse(text); + } + + return response; +}; export type TableError = | NotFoundError @@ -80,14 +83,18 @@ export interface GetTable { * @param res 応答 * @return ページのJSONデータ */ - fromResponse: (res: Response) => Promise>; + fromResponse: ( + res: Response, + ) => Promise>; ( project: string, title: string, filename: string, options?: BaseOptions, - ): Promise>; + ): Promise< + TargetedResponse<200 | 400 | 404, string | TableError | FetchError> + >; } /** 指定したテーブルをCSV形式で得る @@ -107,8 +114,7 @@ export const getTable: GetTable = /* @__PURE__ */ (() => { const { fetch } = setDefaults(options ?? {}); const req = getTable_toRequest(project, title, filename, options); const res = await fetch(req); - if (isErr(res)) return res; - return await getTable_fromResponse(unwrapOk(res)); + return getTable_fromResponse(res); }; fn.toRequest = getTable_toRequest; diff --git a/rest/targeted_response.ts b/rest/targeted_response.ts new file mode 100644 index 0000000..09d9923 --- /dev/null +++ b/rest/targeted_response.ts @@ -0,0 +1,125 @@ +import type { StatusCode, SuccessfulStatus } from "jsr:@std/http"; +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 "jsr:/@std/testing@^1.0.8/types"; + * + * type MappedResponse = MapTargetedResponse<{ + * 200: { success: true }, + * 404: { error: "Not Found" }, + * 500: string, + * }>; + * type _ = AssertTrue + * | TargetedResponse<404, { error: "Not Found" }> + * | TargetedResponse<500, string> + * >>; + * ``` + */ +export type MapTargetedResponse> = { + [K in keyof T]: K extends number + ? T[K] extends Response + ? TargetedResponse + : T[K] extends + | string + | Exclude, string | number | boolean | null> + | Uint8Array + | FormData + | Blob ? TargetedResponse + : never + : never; +}[keyof T]; +export type ResponseOfEndpoint< + ResponseBodyMap extends Record = Record, +> = { + [Status in keyof ResponseBodyMap]: Status extends number + ? ResponseBodyMap[Status] extends + | string + | Exclude< + JsonCompatible, + string | number | boolean | null + > + | Uint8Array + | FormData + | Blob ? TargetedResponse< + ExtendedStatusCodeNumber & Status, + ResponseBodyMap[Status] + > + : never + : never; +}[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} + */ +// Add missing status codes +export type ExtendedStatusCode = + | StatusCode + | 0 + | 400 + | 401 + | 404 + | 408 + | 414 + | 499 + | 500; + +export type ExtendedStatusCodeNumber = ExtendedStatusCode & number; + +export interface TargetedResponse< + Status extends ExtendedStatusCode, + Body = unknown, +> extends Response { + /** + * [HTTP status code](https://developer.mozilla.org/docs/Web/HTTP/Status) + */ + readonly status: Status; + + /** + * Status text corresponding to the status code + */ + readonly statusText: string; + + /** + * Whether the response is successful or not + */ + readonly ok: Status extends SuccessfulStatus ? true : false; + + /** + * Response headers + */ + readonly headers: Headers; + + /** + * Response body + */ + readonly body: ReadableStream | null; + + /** + * Get response body as text + */ + text(): Promise; + + /** + * Get response body as JSON + */ + json(): Promise; + + /** + * Get response body as FormData + */ + formData(): Promise; + + /** + * Clone the response + */ + clone(): TargetedResponse; +} diff --git a/rest/uploadToGCS.ts b/rest/uploadToGCS.ts index 00d6f0a..c8e4177 100644 --- a/rest/uploadToGCS.ts +++ b/rest/uploadToGCS.ts @@ -7,19 +7,13 @@ import { import type { ErrorLike, NotFoundError } from "@cosense/types/rest"; import { md5 } from "@takker/md5"; import { encodeHex } from "@std/encoding/hex"; -import { - createOk, - isErr, - mapAsyncForResult, - mapErrAsyncForResult, - mapForResult, - orElseAsyncForResult, - type Result, - unwrapOk, -} from "option-t/plain_result"; -import { toResultOkFromMaybe } from "option-t/maybe"; import type { FetchError } from "./robustFetch.ts"; -import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts"; +import type { TargetedResponse } from "./targeted_response.ts"; +import { + createErrorResponse, + createSuccessResponse, + createTargetedResponse, +} from "./utils.ts"; /** uploadしたファイルのメタデータ */ export interface GCSFile { @@ -46,14 +40,16 @@ export const uploadToGCS = async ( file: File, projectId: string, options?: ExtendedOptions, -): Promise> => { +): Promise< + TargetedResponse<200 | 400 | 402 | 404, GCSFile | UploadGCSError | FetchError> +> => { const md5Hash = `${encodeHex(md5(await file.arrayBuffer()))}`; const res = await uploadRequest(file, projectId, md5Hash, options); - if (isErr(res)) return res; - const fileOrRequest = unwrapOk(res); - if ("embedUrl" in fileOrRequest) return createOk(fileOrRequest); + if (!res.ok) return res; + const fileOrRequest = res.data; + if ("embedUrl" in fileOrRequest) return createSuccessResponse(fileOrRequest); const result = await upload(fileOrRequest.signedUrl, file, options); - if (isErr(result)) return result; + if (!result.ok) return result; return verify(projectId, fileOrRequest.fileId, md5Hash, options); }; @@ -83,7 +79,10 @@ const uploadRequest = async ( md5: string, init?: ExtendedOptions, ): Promise< - Result + ScrapboxResponse< + GCSFile | UploadRequest, + FileCapacityError | FetchError | HTTPError + > > => { const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); const body = { @@ -92,11 +91,10 @@ const uploadRequest = async ( contentType: file.type, name: file.name, }; - const csrfResult = await orElseAsyncForResult( - toResultOkFromMaybe(csrf), - () => getCSRFToken(init), - ); - if (isErr(csrfResult)) return csrfResult; + + const csrfToken = csrf ?? await getCSRFToken(init); + if (!csrfToken.ok) return csrfToken; + const req = new Request( `https://${hostName}/api/gcs/${projectId}/upload-request`, { @@ -104,27 +102,27 @@ const uploadRequest = async ( body: JSON.stringify(body), headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": unwrapOk(csrfResult), + "X-CSRF-TOKEN": csrfToken.data, ...(sid ? { Cookie: cookie(sid) } : {}), }, }, ); + const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - error.response.status === 402 - ? { - name: "FileCapacityError", - message: (await error.response.json()).message, - } as FileCapacityError - : error, - ), - (res) => res.json(), - ); + const response = createTargetedResponse< + 200 | 400 | 402 | 404, + GCSFile | UploadRequest | FileCapacityError | HTTPError + >(res); + + if (response.status === 402) { + const json = await response.json(); + return createErrorResponse(402, { + name: "FileCapacityError", + message: json.message, + } as FileCapacityError); + } + + return response; }; /** Google Cloud Storage XML APIのerror @@ -140,7 +138,12 @@ const upload = async ( signedUrl: string, file: File, init?: BaseOptions, -): Promise> => { +): Promise< + TargetedResponse< + 200 | 400 | 404, + undefined | GCSError | FetchError | HTTPError + > +> => { const { sid, fetch } = setDefaults(init ?? {}); const res = await fetch( signedUrl, @@ -153,21 +156,22 @@ const upload = async ( }, }, ); - if (isErr(res)) return res; - - return mapForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - error.response.headers.get("Content-Type")?.includes?.("/xml") - ? { - name: "GCSError", - message: await error.response.text(), - } as GCSError - : error, - ), - () => undefined, - ); + + const response = createTargetedResponse< + 200 | 400 | 404, + undefined | GCSError | HTTPError + >(res); + + if ( + !response.ok && response.headers.get("Content-Type")?.includes?.("/xml") + ) { + return createErrorResponse(400, { + name: "GCSError", + message: await response.text(), + } as GCSError); + } + + return response.ok ? createSuccessResponse(undefined) : response; }; /** uploadしたファイルの整合性を確認する */ @@ -176,13 +180,17 @@ const verify = async ( fileId: string, md5: string, init?: ExtendedOptions, -): Promise> => { +): Promise< + TargetedResponse< + 200 | 400 | 404, + GCSFile | NotFoundError | FetchError | HTTPError + > +> => { const { sid, hostName, fetch, csrf } = setDefaults(init ?? {}); - const csrfResult = await orElseAsyncForResult( - toResultOkFromMaybe(csrf), - () => getCSRFToken(init), - ); - if (isErr(csrfResult)) return csrfResult; + + const csrfToken = csrf ?? await getCSRFToken(init); + if (!csrfToken.ok) return csrfToken; + const req = new Request( `https://${hostName}/api/gcs/${projectId}/verify`, { @@ -190,26 +198,25 @@ const verify = async ( body: JSON.stringify({ md5, fileId }), headers: { "Content-Type": "application/json;charset=utf-8", - "X-CSRF-TOKEN": unwrapOk(csrfResult), + "X-CSRF-TOKEN": csrfToken.data, ...(sid ? { Cookie: cookie(sid) } : {}), }, }, ); const res = await fetch(req); - if (isErr(res)) return res; - - return mapAsyncForResult( - await mapErrAsyncForResult( - responseIntoResult(unwrapOk(res)), - async (error) => - error.response.status === 404 - ? { - name: "NotFoundError", - message: (await error.response.json()).message, - } as NotFoundError - : error, - ), - (res) => res.json(), - ); + const response = createTargetedResponse< + 200 | 400 | 404, + GCSFile | NotFoundError | HTTPError + >(res); + + if (response.status === 404) { + const json = await response.json(); + return createErrorResponse(404, { + name: "NotFoundError", + message: json.message, + } as NotFoundError); + } + + return response; }; diff --git a/rest/utils.ts b/rest/utils.ts new file mode 100644 index 0000000..ebd289e --- /dev/null +++ b/rest/utils.ts @@ -0,0 +1,99 @@ +import type { StatusCode, SuccessfulStatus } from "jsr:@std/http"; +import type { JsonCompatible } from "./json_compatible.ts"; +import type { + ExtendedStatusCode, + TargetedResponse, +} from "./targeted_response.ts"; + +/** + * Creates a successful JSON response + */ +export function createSuccessResponse( + body: Body, + init?: ResponseInit, +): TargetedResponse<200, Body> { + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const raw = new Response(JSON.stringify(body), { + ...init, + status: 200, + headers, + }); + + return Object.assign(raw, { + ok: true as const, + status: 200 as const, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + formData: () => Promise.resolve(new FormData()), + clone: () => createSuccessResponse(body, init), + body: raw.body, + bodyUsed: raw.bodyUsed, + redirected: raw.redirected, + type: raw.type, + url: raw.url, + }) as TargetedResponse<200, Body>; +} + +/** + * Creates an error JSON response + */ +export function createErrorResponse< + Status extends Exclude, + Body = unknown, +>( + status: Status, + body: Body, + init?: ResponseInit, +): TargetedResponse { + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const raw = new Response(JSON.stringify(body), { + ...init, + status, + headers, + }); + + return Object.assign(raw, { + ok: false as const, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + formData: () => Promise.resolve(new FormData()), + clone: () => createErrorResponse(status, body, init), + body: raw.body, + bodyUsed: raw.bodyUsed, + redirected: raw.redirected, + type: raw.type, + url: raw.url, + }) as TargetedResponse; +} + +/** + * Creates a TargetedResponse from a standard Response + */ +export function createTargetedResponse< + Status extends StatusCode, + Body extends + | string + | Exclude, string | number | boolean | null> + | Uint8Array + | FormData + | Blob, +>( + response: Response, +): TargetedResponse { + return Object.assign(response, { + status: response.status as Status, + ok: response.ok as Status extends SuccessfulStatus ? true : false, + text: () => response.text() as Promise, + json: () => response.json() as Promise, + formData: () => response.formData() as Promise, + }) as TargetedResponse; +}