Skip to content

Commit 74b49ca

Browse files
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 takker99#213
1 parent 8fffbb2 commit 74b49ca

19 files changed

+682
-601
lines changed

docs/migration-guide-0.30.0.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Migration Guide to v0.30.0
2+
3+
## Breaking Changes
4+
5+
### REST API Changes
6+
7+
The REST API has been completely redesigned to improve type safety, reduce dependencies, and better align with web standards. The main changes are:
8+
9+
1. Removal of `option-t` dependency
10+
- All `Result` types from `option-t/plain_result` have been replaced with `ScrapboxResponse`
11+
- No more `unwrapOk`, `isErr`, or other option-t utilities
12+
13+
2. New `ScrapboxResponse` class
14+
- Extends the web standard `Response` class
15+
- Direct access to `body`, `headers`, and other standard Response properties
16+
- Type-safe error handling based on HTTP status codes
17+
- Built-in JSON parsing with proper typing for success/error cases
18+
19+
### Before and After Examples
20+
21+
#### Before (v0.29.x):
22+
```typescript
23+
import { isErr, unwrapOk } from "option-t/plain_result";
24+
25+
const result = await getProfile();
26+
if (isErr(result)) {
27+
console.error("Failed:", result);
28+
return;
29+
}
30+
const profile = unwrapOk(result);
31+
console.log("Name:", profile.name);
32+
```
33+
34+
#### After (v0.30.0):
35+
```typescript
36+
const response = await getProfile();
37+
if (!response.ok) {
38+
console.error("Failed:", response.error);
39+
return;
40+
}
41+
console.log("Name:", response.data.name);
42+
```
43+
44+
### Key Benefits
45+
46+
1. **Simpler Error Handling**
47+
- HTTP status codes determine error types
48+
- No need to unwrap results manually
49+
- Type-safe error objects with proper typing
50+
51+
2. **Web Standard Compatibility**
52+
- Works with standard web APIs without conversion
53+
- Direct access to Response properties
54+
- Compatible with standard fetch patterns
55+
56+
3. **Better Type Safety**
57+
- Response types change based on HTTP status
58+
- Proper typing for both success and error cases
59+
- No runtime overhead for type checking
60+
61+
### Migration Steps
62+
63+
1. Replace `option-t` imports:
64+
```diff
65+
- import { isErr, unwrapOk } from "option-t/plain_result";
66+
```
67+
68+
2. Update error checking:
69+
```diff
70+
- if (isErr(result)) {
71+
- console.error(result);
72+
+ if (!response.ok) {
73+
+ console.error(response.error);
74+
```
75+
76+
3. Access response data:
77+
```diff
78+
- const data = unwrapOk(result);
79+
+ const data = response.data;
80+
```
81+
82+
4. For direct Response access:
83+
```typescript
84+
// Access headers
85+
const contentType = response.headers.get("content-type");
86+
87+
// Access raw body
88+
const text = await response.text();
89+
90+
// Parse JSON with type safety
91+
const json = await response.json();
92+
```
93+
94+
### Common Patterns
95+
96+
1. **Status-based Error Handling**:
97+
```typescript
98+
const response = await getSnapshot(project, pageId, timestampId);
99+
100+
if (response.status === 422) {
101+
// Handle invalid snapshot ID
102+
console.error("Invalid snapshot:", response.error);
103+
return;
104+
}
105+
106+
if (!response.ok) {
107+
// Handle other errors
108+
console.error("Failed:", response.error);
109+
return;
110+
}
111+
112+
// Use the data
113+
console.log(response.data);
114+
```
115+
116+
2. **Type-safe JSON Parsing**:
117+
```typescript
118+
const response = await getTweetInfo(tweetUrl);
119+
if (response.ok) {
120+
const tweet = response.data; // Properly typed as TweetInfo
121+
console.log(tweet.text);
122+
}
123+
```
124+
125+
3. **Working with Headers**:
126+
```typescript
127+
const response = await uploadToGCS(file, projectId);
128+
if (!response.ok && response.headers.get("Content-Type")?.includes("/xml")) {
129+
console.error("GCS Error:", await response.text());
130+
return;
131+
}
132+
```
133+
134+
### Need Help?
135+
136+
If you encounter any issues during migration, please:
137+
1. Check the examples in this guide
138+
2. Review the [API documentation](https://jsr.io/@takker/scrapbox-userscript-std)
139+
3. Open an issue on GitHub if you need further assistance

rest/auth.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { createOk, mapForResult, type Result } from "option-t/plain_result";
21
import { getProfile } from "./profile.ts";
2+
import { ScrapboxResponse } from "./response.ts";
33
import type { HTTPError } from "./responseIntoResult.ts";
44
import type { AbortError, NetworkError } from "./robustFetch.ts";
55
import type { ExtendedOptions } from "./options.ts";
@@ -16,11 +16,11 @@ export const cookie = (sid: string): string => `connect.sid=${sid}`;
1616
*/
1717
export const getCSRFToken = async (
1818
init?: ExtendedOptions,
19-
): Promise<Result<string, NetworkError | AbortError | HTTPError>> => {
19+
): Promise<ScrapboxResponse<string, NetworkError | AbortError | HTTPError>> => {
2020
// deno-lint-ignore no-explicit-any
2121
const csrf = init?.csrf ?? (globalThis as any)._csrf;
22-
return csrf ? createOk(csrf) : mapForResult(
23-
await getProfile(init),
24-
(user) => user.csrfToken,
25-
);
22+
if (csrf) return ScrapboxResponse.ok(csrf);
23+
24+
const profile = await getProfile(init);
25+
return profile.ok ? ScrapboxResponse.ok(profile.data.csrfToken) : profile;
2626
};

rest/getCodeBlock.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,7 @@ import type {
66
import { cookie } from "./auth.ts";
77
import { encodeTitleURI } from "../title.ts";
88
import { type BaseOptions, setDefaults } from "./options.ts";
9-
import {
10-
isErr,
11-
mapAsyncForResult,
12-
mapErrAsyncForResult,
13-
type Result,
14-
unwrapOk,
15-
} from "option-t/plain_result";
16-
import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts";
9+
import { ScrapboxResponse } from "./response.ts";
1710
import { parseHTTPError } from "./parseHTTPError.ts";
1811
import type { FetchError } from "./mod.ts";
1912

@@ -33,21 +26,28 @@ const getCodeBlock_toRequest: GetCodeBlock["toRequest"] = (
3326
);
3427
};
3528

36-
const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) =>
37-
mapAsyncForResult(
38-
await mapErrAsyncForResult(
39-
responseIntoResult(res),
40-
async (res) =>
41-
res.response.status === 404 &&
42-
res.response.headers.get("Content-Type")?.includes?.("text/plain")
43-
? { name: "NotFoundError", message: "Code block is not found" }
44-
: (await parseHTTPError(res, [
45-
"NotLoggedInError",
46-
"NotMemberError",
47-
])) ?? res,
48-
),
49-
(res) => res.text(),
50-
);
29+
const getCodeBlock_fromResponse: GetCodeBlock["fromResponse"] = async (res) => {
30+
const response = ScrapboxResponse.from<string, CodeBlockError>(res);
31+
32+
if (response.status === 404 && response.headers.get("Content-Type")?.includes?.("text/plain")) {
33+
return ScrapboxResponse.error({
34+
name: "NotFoundError",
35+
message: "Code block is not found",
36+
});
37+
}
38+
39+
await parseHTTPError(response, [
40+
"NotLoggedInError",
41+
"NotMemberError",
42+
]);
43+
44+
if (response.ok) {
45+
const text = await response.text();
46+
return ScrapboxResponse.ok(text);
47+
}
48+
49+
return response;
50+
};
5151

5252
export interface GetCodeBlock {
5353
/** /api/code/:project/:title/:filename の要求を組み立てる
@@ -70,14 +70,14 @@ export interface GetCodeBlock {
7070
* @param res 応答
7171
* @return コード
7272
*/
73-
fromResponse: (res: Response) => Promise<Result<string, CodeBlockError>>;
73+
fromResponse: (res: Response) => Promise<ScrapboxResponse<string, CodeBlockError>>;
7474

7575
(
7676
project: string,
7777
title: string,
7878
filename: string,
7979
options?: BaseOptions,
80-
): Promise<Result<string, CodeBlockError | FetchError>>;
80+
): Promise<ScrapboxResponse<string, CodeBlockError | FetchError>>;
8181
}
8282
export type CodeBlockError =
8383
| NotFoundError
@@ -101,7 +101,7 @@ export const getCodeBlock: GetCodeBlock = /* @__PURE__ */ (() => {
101101
) => {
102102
const req = getCodeBlock_toRequest(project, title, filename, options);
103103
const res = await setDefaults(options ?? {}).fetch(req);
104-
return isErr(res) ? res : getCodeBlock_fromResponse(unwrapOk(res));
104+
return getCodeBlock_fromResponse(res);
105105
};
106106

107107
fn.toRequest = getCodeBlock_toRequest;

rest/getGyazoToken.ts

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
1-
import {
2-
isErr,
3-
mapAsyncForResult,
4-
mapErrAsyncForResult,
5-
type Result,
6-
unwrapOk,
7-
} from "option-t/plain_result";
81
import type { NotLoggedInError } from "@cosense/types/rest";
92
import { cookie } from "./auth.ts";
103
import { parseHTTPError } from "./parseHTTPError.ts";
11-
import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts";
4+
import { ScrapboxResponse } from "./response.ts";
125
import { type BaseOptions, setDefaults } from "./options.ts";
136
import type { FetchError } from "./mod.ts";
147

@@ -29,7 +22,7 @@ export type GyazoTokenError = NotLoggedInError | HTTPError;
2922
*/
3023
export const getGyazoToken = async (
3124
init?: GetGyazoTokenOptions,
32-
): Promise<Result<string | undefined, GyazoTokenError | FetchError>> => {
25+
): Promise<ScrapboxResponse<string | undefined, GyazoTokenError | FetchError>> => {
3326
const { fetch, sid, hostName, gyazoTeamsName } = setDefaults(init ?? {});
3427
const req = new Request(
3528
`https://${hostName}/api/login/gyazo/oauth-upload/token${
@@ -39,14 +32,14 @@ export const getGyazoToken = async (
3932
);
4033

4134
const res = await fetch(req);
42-
if (isErr(res)) return res;
35+
const response = ScrapboxResponse.from<string | undefined, GyazoTokenError>(res);
4336

44-
return mapAsyncForResult(
45-
await mapErrAsyncForResult(
46-
responseIntoResult(unwrapOk(res)),
47-
async (error) =>
48-
(await parseHTTPError(error, ["NotLoggedInError"])) ?? error,
49-
),
50-
(res) => res.json().then((json) => json.token as string | undefined),
51-
);
37+
await parseHTTPError(response, ["NotLoggedInError"]);
38+
39+
if (response.ok) {
40+
const json = await response.json();
41+
return ScrapboxResponse.ok(json.token as string | undefined);
42+
}
43+
44+
return response;
5245
};

rest/getTweetInfo.ts

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
import {
2-
isErr,
3-
mapAsyncForResult,
4-
mapErrAsyncForResult,
5-
type Result,
6-
unwrapOk,
7-
} from "option-t/plain_result";
81
import type {
92
BadRequestError,
103
InvalidURLError,
@@ -13,7 +6,7 @@ import type {
136
} from "@cosense/types/rest";
147
import { cookie, getCSRFToken } from "./auth.ts";
158
import { parseHTTPError } from "./parseHTTPError.ts";
16-
import { type HTTPError, responseIntoResult } from "./responseIntoResult.ts";
9+
import { ScrapboxResponse } from "./response.ts";
1710
import { type ExtendedOptions, setDefaults } from "./options.ts";
1811
import type { FetchError } from "./mod.ts";
1912

@@ -32,11 +25,11 @@ export type TweetInfoError =
3225
export const getTweetInfo = async (
3326
url: string | URL,
3427
init?: ExtendedOptions,
35-
): Promise<Result<TweetInfo, TweetInfoError | FetchError>> => {
28+
): Promise<ScrapboxResponse<TweetInfo, TweetInfoError | FetchError>> => {
3629
const { sid, hostName, fetch } = setDefaults(init ?? {});
3730

38-
const csrfResult = await getCSRFToken(init);
39-
if (isErr(csrfResult)) return csrfResult;
31+
const csrfToken = await getCSRFToken(init);
32+
if (!csrfToken.ok) return csrfToken;
4033

4134
const req = new Request(
4235
`https://${hostName}/api/embed-text/twitter?url=${
@@ -46,33 +39,28 @@ export const getTweetInfo = async (
4639
method: "POST",
4740
headers: {
4841
"Content-Type": "application/json;charset=utf-8",
49-
"X-CSRF-TOKEN": unwrapOk(csrfResult),
42+
"X-CSRF-TOKEN": csrfToken.data,
5043
...(sid ? { Cookie: cookie(sid) } : {}),
5144
},
5245
body: JSON.stringify({ timeout: 3000 }),
5346
},
5447
);
5548

5649
const res = await fetch(req);
57-
if (isErr(res)) return res;
50+
const response = ScrapboxResponse.from<TweetInfo, TweetInfoError>(res);
5851

59-
return mapErrAsyncForResult(
60-
await mapAsyncForResult(
61-
responseIntoResult(unwrapOk(res)),
62-
(res) => res.json() as Promise<TweetInfo>,
63-
),
64-
async (res) => {
65-
if (res.response.status === 422) {
66-
return {
67-
name: "InvalidURLError",
68-
message: (await res.response.json()).message as string,
69-
};
70-
}
71-
const parsed = await parseHTTPError(res, [
72-
"SessionError",
73-
"BadRequestError",
74-
]);
75-
return parsed ?? res;
76-
},
77-
);
52+
if (response.status === 422) {
53+
const json = await response.json();
54+
return ScrapboxResponse.error({
55+
name: "InvalidURLError",
56+
message: json.message as string,
57+
});
58+
}
59+
60+
await parseHTTPError(response, [
61+
"SessionError",
62+
"BadRequestError",
63+
]);
64+
65+
return response;
7866
};

0 commit comments

Comments
 (0)