@tpzdsp/next-toolkit 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -46
- package/package.json +34 -3
- package/src/components/ErrorBoundary/ErrorFallback.stories.tsx +9 -7
- package/src/components/ErrorBoundary/ErrorFallback.test.tsx +67 -14
- package/src/components/ErrorBoundary/ErrorFallback.tsx +66 -12
- package/src/components/ErrorText/ErrorText.tsx +4 -2
- package/src/components/Heading/Heading.tsx +6 -3
- package/src/components/Modal/Modal.tsx +2 -1
- package/src/components/Paragraph/Paragraph.tsx +3 -1
- package/src/components/skipLink/SkipLink.tsx +2 -1
- package/src/errors/ApiError.ts +97 -23
- package/src/http/constants.ts +111 -0
- package/src/http/fetch.ts +263 -0
- package/src/http/index.ts +6 -0
- package/src/http/logger.ts +163 -0
- package/src/http/proxy.ts +269 -0
- package/src/http/query.ts +287 -0
- package/src/http/stream.ts +77 -0
- package/src/map/MapComponent.tsx +1 -1
- package/src/map/basemaps.ts +4 -4
- package/src/types/api.ts +25 -0
- package/src/utils/date.ts +6 -0
- package/src/utils/http.ts +2 -30
- package/src/utils/index.ts +1 -0
- package/src/utils/schema.ts +30 -0
package/src/errors/ApiError.ts
CHANGED
|
@@ -1,33 +1,95 @@
|
|
|
1
1
|
/* eslint-disable no-restricted-syntax */
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import z from 'zod/v4';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { HttpStatus, HttpStatusText } from '../http';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Schema defining the JSON shape of error responses returned by a server. (The proxy is where
|
|
9
|
+
* the error is used the most, though it isn't unique to the proxy and may be used elsewhere).
|
|
10
|
+
*
|
|
11
|
+
* Note: This schema intentionally excludes the HTTP `status` field,
|
|
12
|
+
* as that value is carried in the HTTP response itself (via the status code)
|
|
13
|
+
* and in the in-memory {@link ApiError} class for internal use.
|
|
14
|
+
*/
|
|
15
|
+
export const ApiErrorSchema = z.object({
|
|
16
|
+
message: z.string(),
|
|
17
|
+
details: z.string().nullable(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type ApiErrorSchemaOutput = z.output<typeof ApiErrorSchema>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Represents an error that can be returned by an API or proxy endpoint.
|
|
24
|
+
*
|
|
25
|
+
* Use this class to consistently capture errors along with an HTTP status
|
|
26
|
+
* code and optional detailed message. This allows:
|
|
27
|
+
* - Returning structured errors from API handlers or internal proxies.
|
|
28
|
+
* - Setting the status code for the response when an error has occurred.
|
|
29
|
+
* - Including additional context in `details` for debugging.
|
|
30
|
+
*
|
|
31
|
+
* Note:
|
|
32
|
+
* - Although it contains the same members, this class is separate from {@link ApiErrorSchema}.
|
|
33
|
+
* The schema defines the serialized JSON payload returned to clients, while this class
|
|
34
|
+
* *also* includes meta information of the response, such as the HTTP status code.
|
|
35
|
+
* The status code is typically only used internally inside the proxy (to forward status codes),
|
|
36
|
+
* but it could be accessed if a request 1 made with `throw` set to `false.`
|
|
37
|
+
*/
|
|
38
|
+
export class ApiError extends Error implements z.output<typeof ApiErrorSchema> {
|
|
39
|
+
public readonly details: string | null;
|
|
40
|
+
public readonly digest: string;
|
|
6
41
|
public readonly status: number;
|
|
7
|
-
|
|
8
|
-
|
|
42
|
+
// `true` if this class was reconstructed on the client from an response from the proxy
|
|
43
|
+
private readonly rehydrated: boolean;
|
|
9
44
|
|
|
10
|
-
constructor(
|
|
45
|
+
constructor(
|
|
46
|
+
message: string,
|
|
47
|
+
status: number,
|
|
48
|
+
details?: string | null,
|
|
49
|
+
options?: { rehydrated?: boolean },
|
|
50
|
+
) {
|
|
11
51
|
super(message);
|
|
52
|
+
|
|
12
53
|
this.name = 'ApiError';
|
|
13
54
|
this.status = status;
|
|
14
|
-
this.
|
|
15
|
-
this.
|
|
55
|
+
this.details = details ?? null;
|
|
56
|
+
this.digest = crypto.randomUUID();
|
|
57
|
+
this.rehydrated = options?.rehydrated ?? false;
|
|
16
58
|
|
|
17
59
|
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
18
60
|
if (Error.captureStackTrace) {
|
|
19
61
|
Error.captureStackTrace(this, ApiError);
|
|
20
62
|
}
|
|
63
|
+
|
|
64
|
+
this.logError();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private logError() {
|
|
68
|
+
const trimmedStack = this.stack
|
|
69
|
+
?.split('\n')
|
|
70
|
+
// Filter out noisy frames but keep traces from toolkit for debugging
|
|
71
|
+
.filter((line) => !line.includes('node_modules') || line.includes('next-toolkit'))
|
|
72
|
+
.join('\n');
|
|
73
|
+
|
|
74
|
+
const prefix =
|
|
75
|
+
!this.rehydrated && typeof window !== 'undefined' ? 'Client API Error' : 'Server API Error';
|
|
76
|
+
|
|
77
|
+
console.error(
|
|
78
|
+
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
79
|
+
`[${prefix}]: ${this.message}${this.details ? ` (${this.details})` : ''}. Digest: ${
|
|
80
|
+
this.digest
|
|
81
|
+
}\n${!this.rehydrated ? (trimmedStack ?? 'aa') : 'bb'}`,
|
|
82
|
+
);
|
|
21
83
|
}
|
|
22
84
|
|
|
23
85
|
// Helper method to check if it's a client error (4xx)
|
|
24
86
|
get isClientError(): boolean {
|
|
25
|
-
return this.status >=
|
|
87
|
+
return this.status >= HttpStatus.BadRequest && this.status < HttpStatus.InternalServerError;
|
|
26
88
|
}
|
|
27
89
|
|
|
28
90
|
// Helper method to check if it's a server error (5xx)
|
|
29
91
|
get isServerError(): boolean {
|
|
30
|
-
return this.status >=
|
|
92
|
+
return this.status >= HttpStatus.InternalServerError;
|
|
31
93
|
}
|
|
32
94
|
|
|
33
95
|
// Convert to a plain object for JSON serialization
|
|
@@ -36,36 +98,48 @@ export class ApiError extends Error {
|
|
|
36
98
|
name: this.name,
|
|
37
99
|
message: this.message,
|
|
38
100
|
status: this.status,
|
|
39
|
-
code: this.code,
|
|
40
101
|
details: this.details,
|
|
41
102
|
};
|
|
42
103
|
}
|
|
43
104
|
|
|
44
105
|
// Static factory methods for common error types
|
|
45
|
-
static badRequest(
|
|
46
|
-
return new ApiError(
|
|
106
|
+
static badRequest(details?: string): ApiError {
|
|
107
|
+
return new ApiError(HttpStatusText.BadRequest, HttpStatus.BadRequest, details);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static notFound(details?: string): ApiError {
|
|
111
|
+
return new ApiError(HttpStatusText.NotFound, HttpStatus.NotFound, details);
|
|
47
112
|
}
|
|
48
113
|
|
|
49
|
-
static
|
|
50
|
-
return new ApiError(
|
|
114
|
+
static unauthorized(details?: string): ApiError {
|
|
115
|
+
return new ApiError(HttpStatusText.Unauthorized, HttpStatus.Unauthorized, details);
|
|
51
116
|
}
|
|
52
117
|
|
|
53
|
-
static
|
|
54
|
-
return new ApiError(
|
|
118
|
+
static notAllowed(details?: string): ApiError {
|
|
119
|
+
return new ApiError(HttpStatusText.NotAllowed, HttpStatus.NotAllowed, details);
|
|
55
120
|
}
|
|
56
121
|
|
|
57
|
-
static
|
|
58
|
-
return new ApiError(
|
|
122
|
+
static notImplemented(details?: string): ApiError {
|
|
123
|
+
return new ApiError(HttpStatusText.NotImplemented, HttpStatus.NotImplemented, details);
|
|
59
124
|
}
|
|
60
125
|
|
|
61
|
-
static
|
|
62
|
-
return new ApiError(
|
|
126
|
+
static forbidden(details?: string): ApiError {
|
|
127
|
+
return new ApiError(HttpStatusText.Forbidden, HttpStatus.Forbidden, details);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static unprocessableContent(details?: string): ApiError {
|
|
131
|
+
return new ApiError(
|
|
132
|
+
HttpStatusText.UnprocessableContent,
|
|
133
|
+
HttpStatus.UnprocessableContent,
|
|
134
|
+
details,
|
|
135
|
+
);
|
|
63
136
|
}
|
|
64
137
|
|
|
65
|
-
static
|
|
138
|
+
static internalServerError(details?: string): ApiError {
|
|
66
139
|
return new ApiError(
|
|
67
|
-
|
|
68
|
-
|
|
140
|
+
HttpStatusText.InternalServerError,
|
|
141
|
+
HttpStatus.InternalServerError,
|
|
142
|
+
details,
|
|
69
143
|
);
|
|
70
144
|
}
|
|
71
145
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use the {@link MimeTypes} type for a union type containing these mime types.
|
|
3
|
+
*/
|
|
4
|
+
export const MimeType = {
|
|
5
|
+
Json: 'application/json',
|
|
6
|
+
JsonLd: 'application/ld+json',
|
|
7
|
+
GeoJson: 'application/geo+json',
|
|
8
|
+
Png: 'image/png',
|
|
9
|
+
XJsonLines: 'application/x-jsonlines',
|
|
10
|
+
Csv: 'text/csv',
|
|
11
|
+
Form: 'application/x-www-form-urlencoded',
|
|
12
|
+
Text: 'text/plain',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Use the {@link MimeType} object for named constants of each mime type.
|
|
17
|
+
*/
|
|
18
|
+
export type MimeTypes = (typeof MimeType)[keyof typeof MimeType];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns `true` if the mime type is a non-streamed JSON mime type (I.E. not JSON X-Lines).
|
|
22
|
+
*/
|
|
23
|
+
export const isJsonMimeType = (mime: MimeTypes | string | null): boolean => {
|
|
24
|
+
return ([MimeType.Json, MimeType.GeoJson, MimeType.JsonLd] as string[]).includes(mime ?? '');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns `true` if the mime type is a plain-text mime type.
|
|
29
|
+
*/
|
|
30
|
+
export const isTextMimeType = (mime: MimeTypes | string | null): boolean => {
|
|
31
|
+
return ([MimeType.Text, MimeType.Csv] as string[]).includes(mime ?? '');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Use the {@link HttpStatuses} type for a union type containing these status codes.
|
|
36
|
+
*
|
|
37
|
+
* Use the {@link HttpStatusText} object for the status text represented by each code.
|
|
38
|
+
*/
|
|
39
|
+
export const HttpStatus = {
|
|
40
|
+
Ok: 200,
|
|
41
|
+
Created: 201,
|
|
42
|
+
NoContent: 204,
|
|
43
|
+
BadRequest: 400,
|
|
44
|
+
Unauthorized: 401,
|
|
45
|
+
Forbidden: 403,
|
|
46
|
+
NotFound: 404,
|
|
47
|
+
NotAllowed: 405,
|
|
48
|
+
UnprocessableContent: 422,
|
|
49
|
+
InternalServerError: 500,
|
|
50
|
+
NotImplemented: 501,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Use the {@link HttpStatus} object for named constants of each status code.
|
|
55
|
+
*/
|
|
56
|
+
export type HttpStatuses = (typeof HttpStatus)[keyof typeof HttpStatus];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Status texts for each status code.
|
|
60
|
+
*/
|
|
61
|
+
export const HttpStatusText = {
|
|
62
|
+
Ok: 'OK',
|
|
63
|
+
Created: 'Created',
|
|
64
|
+
NoContent: 'No Content',
|
|
65
|
+
BadRequest: 'Bad Request',
|
|
66
|
+
Unauthorized: 'Unauthorized',
|
|
67
|
+
Forbidden: 'Forbidden',
|
|
68
|
+
NotFound: 'Not Found',
|
|
69
|
+
NotAllowed: 'Method Not Allowed',
|
|
70
|
+
UnprocessableContent: 'Unprocessable Content',
|
|
71
|
+
InternalServerError: 'Internal Server Error',
|
|
72
|
+
NotImplemented: 'Not Implemented',
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Use the {@link HttpMethods} type for a union type containing these method verbs.
|
|
77
|
+
*/
|
|
78
|
+
export const HttpMethod = {
|
|
79
|
+
Get: 'get',
|
|
80
|
+
Post: 'post',
|
|
81
|
+
Put: 'put',
|
|
82
|
+
Patch: 'patch',
|
|
83
|
+
Delete: 'delete',
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Use the {@link HttpMethod} object for named constants of each method verb.
|
|
88
|
+
*/
|
|
89
|
+
export type HttpMethods = (typeof HttpMethod)[keyof typeof HttpMethod];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Use the {@link Headers} type for a union type containing these headers.
|
|
93
|
+
*/
|
|
94
|
+
export const Header = {
|
|
95
|
+
ContentType: 'Content-Type',
|
|
96
|
+
ContentDisposition: 'Content-Disposition',
|
|
97
|
+
Accept: 'Accept',
|
|
98
|
+
AcceptCrs: 'Accept-Crs',
|
|
99
|
+
CsvHeader: 'CSV-Header',
|
|
100
|
+
XTotalItems: 'X-Total-Items',
|
|
101
|
+
XRequestId: 'X-Request-ID',
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Use the {@link Header} object for named constants of each header.
|
|
106
|
+
*/
|
|
107
|
+
export type Headers = (typeof Header)[keyof typeof Header];
|
|
108
|
+
|
|
109
|
+
// Special types used for downloading files via a `POST` request
|
|
110
|
+
export const SPECIAL_FORM_DATA_TYPE = '_type';
|
|
111
|
+
export const SPECIAL_FORM_DOWNLOAD_POST = 'file-download';
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import z from 'zod/v4';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createSchema as betterFetchCreateSchema,
|
|
6
|
+
createFetch as betterFetchCreateFetch,
|
|
7
|
+
type CreateFetchOption as BetterFetchCreateFetchOption,
|
|
8
|
+
type Schema,
|
|
9
|
+
type FetchSchema,
|
|
10
|
+
ValidationError,
|
|
11
|
+
BetterFetchError,
|
|
12
|
+
type BetterFetch,
|
|
13
|
+
type StandardSchemaV1,
|
|
14
|
+
type BetterFetchPlugin,
|
|
15
|
+
} from '@better-fetch/fetch';
|
|
16
|
+
|
|
17
|
+
import { ApiError } from '../errors';
|
|
18
|
+
import { Header, isJsonMimeType, MimeType, type HttpMethods } from './constants';
|
|
19
|
+
import { HttpStatus } from './constants';
|
|
20
|
+
import { readJsonXLinesStream } from './stream';
|
|
21
|
+
import type { ApiErrorSchemaOutput } from '../errors/ApiError';
|
|
22
|
+
|
|
23
|
+
type NodeGlobal = {
|
|
24
|
+
process?: {
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isNodeEnvironment = (): boolean => {
|
|
30
|
+
const global = globalThis as NodeGlobal;
|
|
31
|
+
|
|
32
|
+
return typeof global.process?.env !== 'undefined';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const getNodeEnvVar = (key: string): string | undefined => {
|
|
36
|
+
if (!isNodeEnvironment()) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const global = globalThis as NodeGlobal;
|
|
41
|
+
|
|
42
|
+
return global.process?.env?.[key];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getAuth = (): CreateFetchOption['auth'] => {
|
|
46
|
+
const apiHasAuth = getNodeEnvVar('API_HAS_AUTH');
|
|
47
|
+
const apiUsername = getNodeEnvVar('API_USERNAME');
|
|
48
|
+
const apiPassword = getNodeEnvVar('API_PASSWORD');
|
|
49
|
+
|
|
50
|
+
if (apiHasAuth === 'true' && apiUsername && apiPassword) {
|
|
51
|
+
return {
|
|
52
|
+
type: 'Basic',
|
|
53
|
+
username: apiUsername,
|
|
54
|
+
password: apiPassword,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Tries to gracefully format a value as a string, for printing in the logger
|
|
62
|
+
const safeString = (value: unknown): string => {
|
|
63
|
+
try {
|
|
64
|
+
const string = typeof value === 'string' ? value : JSON.stringify(value);
|
|
65
|
+
|
|
66
|
+
if (!string) {
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return string;
|
|
71
|
+
} catch {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Captures any possible error from the fetch and turns it into an instance of our class, so all our errors are consistent
|
|
77
|
+
export const normalizeError = async (
|
|
78
|
+
error: unknown,
|
|
79
|
+
options?: CreateFetchOption,
|
|
80
|
+
): Promise<ApiError> => {
|
|
81
|
+
if (error instanceof ApiError) {
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validation errors from schemas
|
|
86
|
+
if (error instanceof ValidationError) {
|
|
87
|
+
return new ApiError(
|
|
88
|
+
'Response schema validation failed.',
|
|
89
|
+
HttpStatus.BadRequest,
|
|
90
|
+
z.prettifyError(error),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Upstream from better-fetch or when server responds with error
|
|
95
|
+
if (error instanceof BetterFetchError) {
|
|
96
|
+
// For some reason the library doesn't implement any functionality with the `errorSchema` option yet,
|
|
97
|
+
// so we do the validation ourselves (using the StandardSchemaV1 interface so it is zod-agnostic)
|
|
98
|
+
const parsed = await options?.errorSchema?.['~standard'].validate(error.error);
|
|
99
|
+
|
|
100
|
+
if (!parsed?.issues && parsed?.value) {
|
|
101
|
+
const { message, details } = parsed.value;
|
|
102
|
+
|
|
103
|
+
return new ApiError(message, error.status, details, {
|
|
104
|
+
// if we're reconstructing this error on the client from an error on the server, don't print the stack trace
|
|
105
|
+
rehydrated: typeof window !== 'undefined',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (parsed?.issues) {
|
|
110
|
+
return new ApiError(error.statusText, error.status, z.prettifyError(parsed));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new ApiError(
|
|
114
|
+
error.statusText,
|
|
115
|
+
error.status,
|
|
116
|
+
'Unknown error: validation returned empty result with no known issues reported.',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Internal errors
|
|
121
|
+
if (error instanceof Error) {
|
|
122
|
+
return new ApiError(error.message, HttpStatus.InternalServerError, safeString(error.cause));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return ApiError.internalServerError('Unknown cause.');
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Override the options to enforce that an error schema is provided
|
|
129
|
+
type CreateFetchOption = Omit<BetterFetchCreateFetchOption, 'errorSchema'> & {
|
|
130
|
+
errorSchema: StandardSchemaV1<unknown, ApiErrorSchemaOutput>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Override the schema options so we enforce a method is specified for each route
|
|
134
|
+
type SchemaRoutes = Record<string, Omit<FetchSchema, 'method'> & { method: HttpMethods }>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wrapper function around `@better-fetch/fetch` `createSchema` which creates a strict schema.
|
|
138
|
+
*
|
|
139
|
+
* A fetch schema is used to describe a list of routes, their HTTP method, inputs, and outputs.
|
|
140
|
+
*
|
|
141
|
+
* @param schema
|
|
142
|
+
* @param config
|
|
143
|
+
* @returns
|
|
144
|
+
*/
|
|
145
|
+
export const createSchema = <Routes extends SchemaRoutes>(schema: Routes) => {
|
|
146
|
+
return betterFetchCreateSchema(schema, {
|
|
147
|
+
strict: true,
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Utility type to merge two objects (used for merging schemas together)
|
|
152
|
+
type MergeTwo<A, B> = {
|
|
153
|
+
[K in keyof A | keyof B]: K extends keyof B ? B[K] : K extends keyof A ? A[K] : never;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Takes an array of schemas and merges all the routes together
|
|
157
|
+
type MergeSchemasInner<T extends Schema[], Key extends keyof Schema> = T extends [
|
|
158
|
+
infer First extends Schema,
|
|
159
|
+
...infer Rest extends Schema[],
|
|
160
|
+
]
|
|
161
|
+
? MergeTwo<First[Key], MergeSchemasInner<Rest, Key>>
|
|
162
|
+
: object;
|
|
163
|
+
|
|
164
|
+
// Merges the schemas from an array, so multiple schemas can be provided.
|
|
165
|
+
type MergeSchemas<T extends Schema[]> = {
|
|
166
|
+
schema: MergeSchemasInner<T, 'schema'>;
|
|
167
|
+
config: MergeSchemasInner<T, 'config'>;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// A custom plugin mainly used to fix a few bugs or add handling to extra content types, etc.
|
|
171
|
+
const TOOLKIT_PLUGIN = {
|
|
172
|
+
id: 'toolkit-plugin',
|
|
173
|
+
name: 'Toolkit Plugin',
|
|
174
|
+
init: (url, options) => {
|
|
175
|
+
// If the body is `null` but no content type was supplied, it will try and detect it but error
|
|
176
|
+
// due to accessing a field on a null value. We handle this by setting the content type header
|
|
177
|
+
// if it doesn't exist and the body is null.
|
|
178
|
+
if (options?.body === null && !(options.headers as any)?.[Header.ContentType]) {
|
|
179
|
+
(options.headers as any)[Header.ContentType] = MimeType.Json;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { url, options };
|
|
183
|
+
},
|
|
184
|
+
hooks: {
|
|
185
|
+
onRequest: (context) => {
|
|
186
|
+
// for some reason if you specify a JSON content type, better-fetch will not stringify the body
|
|
187
|
+
// but as we also have extra JSON mime types, we need to handle that
|
|
188
|
+
if (isJsonMimeType(context.headers.get(Header.ContentType))) {
|
|
189
|
+
context.body = JSON.stringify(context.body);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return context;
|
|
193
|
+
},
|
|
194
|
+
onResponse: (context) => {
|
|
195
|
+
if (context.response.headers.get(Header.ContentType)?.includes(MimeType.XJsonLines)) {
|
|
196
|
+
// `better-fetch` does not allow for handling of content types it doesn't know about, and it will
|
|
197
|
+
// always fall back to `.blob()`. We need to stream JSON X-Lines, so I do a hack to replace
|
|
198
|
+
// the blob function to return the streamed data instead. I am confident this is okay, as not only
|
|
199
|
+
// will it replace the function ONLY for this one content type, our custom plugin is always the LAST
|
|
200
|
+
// in the array, so it wouldn't mess with any other plugins that may use this.
|
|
201
|
+
(context.response as any).blob = async () => {
|
|
202
|
+
return await readJsonXLinesStream(context.response);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return context;
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
} satisfies BetterFetchPlugin;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Creates a fully typed and schema-validated fetch client by combining one or more `better-fetch` schemas.
|
|
213
|
+
*
|
|
214
|
+
* This wrapper extends the base `better-fetch` `createFetch` with:
|
|
215
|
+
* - **Automatic schema merging** — allows multiple schemas to be combined into one fetch instance.
|
|
216
|
+
* - **Enhanced error handling** — all thrown errors are normalized into consistent {@link ApiError} instances.
|
|
217
|
+
* - **Toolkit plugin** — adds extra handling for JSON content types, streaming JSON X-Lines responses,
|
|
218
|
+
* and fixes some edge-case behaviors in the library.
|
|
219
|
+
* - **Automatic basic auth** — reads credentials from environment variables if available.
|
|
220
|
+
*
|
|
221
|
+
* The resulting client can be used exactly like a `better-fetch` instance, with full type inference
|
|
222
|
+
* for routes, inputs, and outputs derived from the merged schemas.
|
|
223
|
+
*
|
|
224
|
+
* @template Schemas - One or more `better-fetch` schemas to merge.
|
|
225
|
+
* @template Option - Configuration options, including enforced `errorSchema` type.
|
|
226
|
+
* @param schemas - An array of fetch schemas to merge into a single client.
|
|
227
|
+
* @param config - Optional configuration object passed to `better-fetch`, extended with defaults and plugins.
|
|
228
|
+
* @returns A `BetterFetch` instance with merged schema routes, automatic plugin registration, and normalized error handling.
|
|
229
|
+
*
|
|
230
|
+
* @see {@link https://github.com/better-fetch/better-fetch | better-fetch on GitHub}
|
|
231
|
+
*/
|
|
232
|
+
export const createFetch = <
|
|
233
|
+
Schemas extends Schema[],
|
|
234
|
+
Option extends CreateFetchOption = CreateFetchOption,
|
|
235
|
+
>(
|
|
236
|
+
schemas: [...Schemas],
|
|
237
|
+
config?: Option,
|
|
238
|
+
): BetterFetch<
|
|
239
|
+
Omit<Option, 'schema'> & {
|
|
240
|
+
schema: MergeSchemas<Schemas>;
|
|
241
|
+
}
|
|
242
|
+
> => {
|
|
243
|
+
const mergedSchema = {
|
|
244
|
+
schema: Object.assign({}, ...schemas.map((s) => s.schema)),
|
|
245
|
+
config: Object.assign({}, ...schemas.map((s) => s.config)),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const instance = betterFetchCreateFetch({
|
|
249
|
+
schema: mergedSchema,
|
|
250
|
+
auth: getAuth(),
|
|
251
|
+
...config,
|
|
252
|
+
plugins: [...(config?.plugins ?? []), TOOLKIT_PLUGIN],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Wrap the fetcher to catch & normalize errors
|
|
256
|
+
return (async (...args) => {
|
|
257
|
+
try {
|
|
258
|
+
return await instance(...args);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
throw await normalizeError(err, config);
|
|
261
|
+
}
|
|
262
|
+
}) as typeof instance;
|
|
263
|
+
};
|