@wp-typia/rest 0.2.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 ADDED
@@ -0,0 +1,46 @@
1
+ # `@wp-typia/rest`
2
+
3
+ Typed WordPress REST helpers for `wp-typia`.
4
+
5
+ This package focuses on:
6
+
7
+ - validated `apiFetch` wrappers
8
+ - typed endpoint helpers
9
+ - canonical WordPress REST route URL resolution
10
+ - optional query/header decoder helpers that can wrap Typia-generated HTTP decoders
11
+
12
+ It does not include any WordPress PHP bridge logic. Generated PHP route code stays in `@wp-typia/create` templates.
13
+
14
+ Typical usage:
15
+
16
+ ```ts
17
+ import { callEndpoint, createEndpoint } from "@wp-typia/rest";
18
+
19
+ const endpoint = createEndpoint<MyRequest, MyResponse>({
20
+ method: "POST",
21
+ path: "/my-namespace/v1/demo",
22
+ validateRequest: validators.request,
23
+ validateResponse: validators.response,
24
+ });
25
+
26
+ const result = await callEndpoint(endpoint, { title: "Hello" });
27
+ ```
28
+
29
+ If you need a canonical REST URL for a route path, use:
30
+
31
+ ```ts
32
+ import { resolveRestRouteUrl } from "@wp-typia/rest";
33
+
34
+ const url = resolveRestRouteUrl("/my-namespace/v1/demo");
35
+ ```
36
+
37
+ If you want Typia-powered HTTP decoding, compile the decoder in the consumer project and pass it in:
38
+
39
+ ```ts
40
+ import typia from "typia";
41
+ import { createQueryDecoder } from "@wp-typia/rest";
42
+
43
+ const decodeQuery = createQueryDecoder(
44
+ typia.http.createValidateQuery<MyQuery>()
45
+ );
46
+ ```
@@ -0,0 +1,45 @@
1
+ import type { APIFetchOptions, ApiFetch } from "@wordpress/api-fetch";
2
+ import type { IValidation } from "@typia/interface";
3
+ export interface ValidationError {
4
+ description?: string;
5
+ expected: string;
6
+ path: string;
7
+ value: unknown;
8
+ }
9
+ export interface ValidationResult<T> {
10
+ data?: T;
11
+ errors: ValidationError[];
12
+ isValid: boolean;
13
+ }
14
+ export type ValidationLike<T> = IValidation<T> | {
15
+ data?: unknown;
16
+ errors?: unknown;
17
+ success?: unknown;
18
+ };
19
+ export interface ValidatedFetch<T> {
20
+ assertFetch(options: APIFetchOptions): Promise<T>;
21
+ fetch(options: APIFetchOptions): Promise<ValidationResult<T>>;
22
+ fetchWithResponse(options: APIFetchOptions<false>): Promise<{
23
+ response: Response;
24
+ validation: ValidationResult<T>;
25
+ }>;
26
+ isFetch(options: APIFetchOptions): Promise<T | null>;
27
+ }
28
+ export interface ApiEndpoint<Req, Res> {
29
+ buildRequestOptions?: (request: Req) => Partial<APIFetchOptions>;
30
+ method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
31
+ path: string;
32
+ validateRequest: (input: unknown) => ValidationResult<Req>;
33
+ validateResponse: (input: unknown) => ValidationResult<Res>;
34
+ }
35
+ export interface EndpointCallOptions {
36
+ fetchFn?: ApiFetch;
37
+ requestOptions?: Partial<APIFetchOptions>;
38
+ }
39
+ export declare function resolveRestRouteUrl(routePath: string, root?: string): string;
40
+ export declare function normalizeValidationError(error: unknown): ValidationError;
41
+ export declare function isValidationResult<T>(value: unknown): value is ValidationResult<T>;
42
+ export declare function toValidationResult<T>(result: ValidationLike<T>): ValidationResult<T>;
43
+ export declare function createValidatedFetch<T>(validator: (input: unknown) => ValidationLike<T>, fetchFn?: ApiFetch): ValidatedFetch<T>;
44
+ export declare function createEndpoint<Req, Res>(config: ApiEndpoint<Req, Res>): ApiEndpoint<Req, Res>;
45
+ export declare function callEndpoint<Req, Res>(endpoint: ApiEndpoint<Req, Res>, request: Req, { fetchFn, requestOptions }?: EndpointCallOptions): Promise<ValidationResult<Res>>;
package/dist/client.js ADDED
@@ -0,0 +1,285 @@
1
+ function getDefaultRestRoot() {
2
+ if (typeof window !== "undefined") {
3
+ const wpApiSettings = window.wpApiSettings;
4
+ if (typeof wpApiSettings?.root === "string" && wpApiSettings.root.length > 0) {
5
+ return wpApiSettings.root;
6
+ }
7
+ if (typeof document !== "undefined") {
8
+ const apiLink = document.querySelector('link[rel="https://api.w.org/"]');
9
+ const href = apiLink?.getAttribute("href");
10
+ if (typeof href === "string" && href.length > 0) {
11
+ return new URL(href, window.location.origin).toString();
12
+ }
13
+ }
14
+ }
15
+ throw new Error("Unable to resolve the WordPress REST root automatically. Provide wpApiSettings.root, an api.w.org discovery link, or an explicit url.");
16
+ }
17
+ export function resolveRestRouteUrl(routePath, root = getDefaultRestRoot()) {
18
+ const [pathWithQuery, hash = ""] = routePath.split("#", 2);
19
+ const [rawPath, rawQuery = ""] = pathWithQuery.split("?", 2);
20
+ const normalizedRoute = `/${rawPath.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
21
+ const queryParams = new URLSearchParams(rawQuery);
22
+ const resolvedRoot = typeof window !== "undefined" ? new URL(root, window.location.origin) : new URL(root);
23
+ if (resolvedRoot.searchParams.has("rest_route")) {
24
+ resolvedRoot.searchParams.set("rest_route", normalizedRoute);
25
+ for (const [key, value] of queryParams) {
26
+ resolvedRoot.searchParams.append(key, value);
27
+ }
28
+ if (hash) {
29
+ resolvedRoot.hash = hash;
30
+ }
31
+ return resolvedRoot.toString();
32
+ }
33
+ const basePath = resolvedRoot.pathname.endsWith("/") ? resolvedRoot.pathname : `${resolvedRoot.pathname}/`;
34
+ resolvedRoot.pathname = `${basePath}${normalizedRoute.slice(1)}`;
35
+ for (const [key, value] of queryParams) {
36
+ resolvedRoot.searchParams.append(key, value);
37
+ }
38
+ if (hash) {
39
+ resolvedRoot.hash = hash;
40
+ }
41
+ return resolvedRoot.toString();
42
+ }
43
+ function resolveFetchUrl(options) {
44
+ if (typeof options.url === "string" && options.url.length > 0) {
45
+ return options.url;
46
+ }
47
+ if (typeof options.path === "string" && options.path.length > 0) {
48
+ return resolveRestRouteUrl(options.path);
49
+ }
50
+ throw new Error("API fetch options must include either a path or a url.");
51
+ }
52
+ async function defaultFetch(options) {
53
+ const response = await fetch(resolveFetchUrl(options), {
54
+ body: options.body,
55
+ credentials: "same-origin",
56
+ headers: options.headers,
57
+ method: options.method ?? "GET",
58
+ });
59
+ if (options.parse === false) {
60
+ return response;
61
+ }
62
+ if (response.status === 204) {
63
+ return undefined;
64
+ }
65
+ const text = await response.text();
66
+ if (!text) {
67
+ return undefined;
68
+ }
69
+ try {
70
+ return JSON.parse(text);
71
+ }
72
+ catch {
73
+ return text;
74
+ }
75
+ }
76
+ async function parseResponsePayload(response) {
77
+ if (response.status === 204) {
78
+ return undefined;
79
+ }
80
+ const text = await response.text();
81
+ if (!text) {
82
+ return undefined;
83
+ }
84
+ try {
85
+ return JSON.parse(text);
86
+ }
87
+ catch {
88
+ return text;
89
+ }
90
+ }
91
+ function isPlainObject(value) {
92
+ return value !== null && typeof value === "object" && !Array.isArray(value);
93
+ }
94
+ function isFormDataLike(value) {
95
+ return typeof FormData !== "undefined" && value instanceof FormData;
96
+ }
97
+ function normalizePath(path) {
98
+ return typeof path === "string" && path.length > 0 ? path : "(root)";
99
+ }
100
+ function normalizeExpected(expected) {
101
+ return typeof expected === "string" && expected.length > 0 ? expected : "unknown";
102
+ }
103
+ export function normalizeValidationError(error) {
104
+ const raw = isPlainObject(error) ? error : {};
105
+ return {
106
+ description: typeof raw.description === "string" ? raw.description : undefined,
107
+ expected: normalizeExpected(raw.expected),
108
+ path: normalizePath(raw.path),
109
+ value: Object.prototype.hasOwnProperty.call(raw, "value") ? raw.value : undefined,
110
+ };
111
+ }
112
+ export function isValidationResult(value) {
113
+ return isPlainObject(value) && typeof value.isValid === "boolean" && Array.isArray(value.errors);
114
+ }
115
+ export function toValidationResult(result) {
116
+ const rawResult = result;
117
+ if (isValidationResult(result)) {
118
+ return result;
119
+ }
120
+ if (rawResult.success === true) {
121
+ return {
122
+ data: rawResult.data,
123
+ errors: [],
124
+ isValid: true,
125
+ };
126
+ }
127
+ return {
128
+ data: undefined,
129
+ errors: Array.isArray(rawResult.errors) ? rawResult.errors.map(normalizeValidationError) : [],
130
+ isValid: false,
131
+ };
132
+ }
133
+ function encodeGetLikeRequest(request) {
134
+ if (request === undefined || request === null) {
135
+ return "";
136
+ }
137
+ if (request instanceof URLSearchParams) {
138
+ return request.toString();
139
+ }
140
+ if (!isPlainObject(request)) {
141
+ throw new Error("GET/DELETE endpoint requests must be plain objects or URLSearchParams.");
142
+ }
143
+ const params = new URLSearchParams();
144
+ for (const [key, value] of Object.entries(request)) {
145
+ if (value === undefined || value === null) {
146
+ continue;
147
+ }
148
+ if (Array.isArray(value)) {
149
+ for (const item of value) {
150
+ params.append(key, String(item));
151
+ }
152
+ continue;
153
+ }
154
+ params.set(key, String(value));
155
+ }
156
+ return params.toString();
157
+ }
158
+ function joinPathWithQuery(path, query) {
159
+ if (!query) {
160
+ return path;
161
+ }
162
+ return path.includes("?") ? `${path}&${query}` : `${path}?${query}`;
163
+ }
164
+ function joinUrlWithQuery(url, query) {
165
+ if (!query) {
166
+ return url;
167
+ }
168
+ const nextUrl = new URL(url, typeof window !== "undefined" ? window.location.origin : "http://localhost");
169
+ for (const [key, value] of new URLSearchParams(query)) {
170
+ nextUrl.searchParams.append(key, value);
171
+ }
172
+ return nextUrl.toString();
173
+ }
174
+ function mergeHeaderInputs(baseHeaders, requestHeaders) {
175
+ if (!baseHeaders && !requestHeaders) {
176
+ return undefined;
177
+ }
178
+ const mergedHeaders = new Headers(baseHeaders);
179
+ const nextHeaders = new Headers(requestHeaders);
180
+ for (const [key, value] of nextHeaders.entries()) {
181
+ mergedHeaders.set(key, value);
182
+ }
183
+ return Object.fromEntries(mergedHeaders.entries());
184
+ }
185
+ function buildEndpointFetchOptions(endpoint, request) {
186
+ const baseOptions = endpoint.buildRequestOptions?.(request) ?? {};
187
+ if (endpoint.method === "GET" || endpoint.method === "DELETE") {
188
+ const query = encodeGetLikeRequest(request);
189
+ const resolvedUrl = baseOptions.url ? joinUrlWithQuery(baseOptions.url, query) : undefined;
190
+ return {
191
+ ...baseOptions,
192
+ method: endpoint.method,
193
+ ...(resolvedUrl ? { url: resolvedUrl, path: undefined } : {
194
+ path: joinPathWithQuery(baseOptions.path ?? endpoint.path, query),
195
+ }),
196
+ };
197
+ }
198
+ if (isFormDataLike(request)) {
199
+ return {
200
+ ...baseOptions,
201
+ body: request,
202
+ method: endpoint.method,
203
+ ...(baseOptions.url
204
+ ? {
205
+ url: baseOptions.url,
206
+ path: undefined,
207
+ }
208
+ : {
209
+ path: baseOptions.path ?? endpoint.path,
210
+ }),
211
+ };
212
+ }
213
+ return {
214
+ ...baseOptions,
215
+ body: typeof request === "string" ? request : JSON.stringify(request),
216
+ headers: mergeHeaderInputs({ "Content-Type": "application/json" }, baseOptions.headers),
217
+ method: endpoint.method,
218
+ ...(baseOptions.url
219
+ ? {
220
+ url: baseOptions.url,
221
+ path: undefined,
222
+ }
223
+ : {
224
+ path: baseOptions.path ?? endpoint.path,
225
+ }),
226
+ };
227
+ }
228
+ function mergeFetchOptions(baseOptions, requestOptions) {
229
+ if (!requestOptions) {
230
+ return baseOptions;
231
+ }
232
+ const { headers: requestHeaders, ...transportOptions } = requestOptions;
233
+ return {
234
+ ...baseOptions,
235
+ ...transportOptions,
236
+ headers: mergeHeaderInputs(baseOptions.headers, requestHeaders),
237
+ };
238
+ }
239
+ export function createValidatedFetch(validator, fetchFn = defaultFetch) {
240
+ return {
241
+ async fetchWithResponse(options) {
242
+ const response = await fetchFn({
243
+ ...options,
244
+ parse: false,
245
+ });
246
+ const payload = await parseResponsePayload(response.clone());
247
+ return {
248
+ response,
249
+ validation: toValidationResult(validator(payload)),
250
+ };
251
+ },
252
+ async fetch(options) {
253
+ if (options.parse === false) {
254
+ const { validation } = await this.fetchWithResponse(options);
255
+ return validation;
256
+ }
257
+ const payload = await fetchFn(options);
258
+ return toValidationResult(validator(payload));
259
+ },
260
+ async assertFetch(options) {
261
+ const result = await this.fetch(options);
262
+ if (!result.isValid) {
263
+ throw new Error(result.errors[0]
264
+ ? `${result.errors[0].path}: ${result.errors[0].expected}`
265
+ : "REST response validation failed.");
266
+ }
267
+ return result.data;
268
+ },
269
+ async isFetch(options) {
270
+ const result = await this.fetch(options);
271
+ return result.isValid ? result.data : null;
272
+ },
273
+ };
274
+ }
275
+ export function createEndpoint(config) {
276
+ return config;
277
+ }
278
+ export async function callEndpoint(endpoint, request, { fetchFn = defaultFetch, requestOptions } = {}) {
279
+ const requestValidation = endpoint.validateRequest(request);
280
+ if (!requestValidation.isValid) {
281
+ return requestValidation;
282
+ }
283
+ const payload = await fetchFn(mergeFetchOptions(buildEndpointFetchOptions(endpoint, request), requestOptions));
284
+ return endpoint.validateResponse(payload);
285
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { IValidation } from "@typia/interface";
2
+ import { type ValidationLike, type ValidationResult } from "./client.js";
3
+ export declare function createQueryDecoder<T extends object>(validate?: (input: string | URLSearchParams) => ValidationLike<T> | IValidation<T>): (input: unknown) => ValidationResult<T>;
4
+ export declare function createHeadersDecoder<T extends object>(validate?: (input: Record<string, string | string[] | undefined>) => ValidationLike<T> | IValidation<T>): (input: unknown) => ValidationResult<T>;
5
+ export declare function createParameterDecoder<T extends string | number | boolean | bigint | null>(): (input: string) => T;
package/dist/http.js ADDED
@@ -0,0 +1,99 @@
1
+ import { toValidationResult, } from "./client.js";
2
+ function toHeadersRecord(input) {
3
+ if (input instanceof Headers) {
4
+ const record = {};
5
+ input.forEach((value, key) => {
6
+ record[key] = value;
7
+ });
8
+ return record;
9
+ }
10
+ if (input !== null && typeof input === "object" && !Array.isArray(input)) {
11
+ return input;
12
+ }
13
+ return {};
14
+ }
15
+ function toQueryInput(input) {
16
+ if (typeof input === "string" || input instanceof URLSearchParams) {
17
+ return input;
18
+ }
19
+ if (input !== null && typeof input === "object" && !Array.isArray(input)) {
20
+ const params = new URLSearchParams();
21
+ for (const [key, value] of Object.entries(input)) {
22
+ if (value === undefined || value === null) {
23
+ continue;
24
+ }
25
+ if (Array.isArray(value)) {
26
+ for (const item of value) {
27
+ params.append(key, String(item));
28
+ }
29
+ continue;
30
+ }
31
+ params.set(key, String(value));
32
+ }
33
+ return params;
34
+ }
35
+ return "";
36
+ }
37
+ function toQueryRecord(input) {
38
+ const params = typeof input === "string" ? new URLSearchParams(input) : input;
39
+ return Object.fromEntries(params.entries());
40
+ }
41
+ export function createQueryDecoder(validate) {
42
+ return (input) => {
43
+ const queryInput = toQueryInput(input);
44
+ if (validate) {
45
+ return toValidationResult(validate(queryInput));
46
+ }
47
+ return {
48
+ data: toQueryRecord(queryInput),
49
+ errors: [],
50
+ isValid: true,
51
+ };
52
+ };
53
+ }
54
+ export function createHeadersDecoder(validate) {
55
+ return (input) => {
56
+ const headersRecord = toHeadersRecord(input);
57
+ if (validate) {
58
+ return toValidationResult(validate(headersRecord));
59
+ }
60
+ return {
61
+ data: headersRecord,
62
+ errors: [],
63
+ isValid: true,
64
+ };
65
+ };
66
+ }
67
+ function decodePrimitiveParameter(input) {
68
+ if (input === "null") {
69
+ return null;
70
+ }
71
+ if (input === "true") {
72
+ return true;
73
+ }
74
+ if (input === "false") {
75
+ return false;
76
+ }
77
+ if (/^-?\d+$/.test(input)) {
78
+ const numericValue = Number(input);
79
+ if (Number.isSafeInteger(numericValue)) {
80
+ return numericValue;
81
+ }
82
+ try {
83
+ return BigInt(input);
84
+ }
85
+ catch {
86
+ return input;
87
+ }
88
+ }
89
+ if (/^-?(?:\d+\.\d+|\d+\.|\.\d+)$/.test(input)) {
90
+ const numericValue = Number(input);
91
+ if (!Number.isNaN(numericValue)) {
92
+ return numericValue;
93
+ }
94
+ }
95
+ return input;
96
+ }
97
+ export function createParameterDecoder() {
98
+ return ((input) => decodePrimitiveParameter(input));
99
+ }
@@ -0,0 +1,2 @@
1
+ export { createEndpoint, createValidatedFetch, callEndpoint, isValidationResult, normalizeValidationError, resolveRestRouteUrl, toValidationResult, type ApiEndpoint, type EndpointCallOptions, type ValidatedFetch, type ValidationResult, type ValidationError, type ValidationLike, } from "./client.js";
2
+ export { createHeadersDecoder, createParameterDecoder, createQueryDecoder, } from "./http.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createEndpoint, createValidatedFetch, callEndpoint, isValidationResult, normalizeValidationError, resolveRestRouteUrl, toValidationResult, } from "./client.js";
2
+ export { createHeadersDecoder, createParameterDecoder, createQueryDecoder, } from "./http.js";
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@wp-typia/rest",
3
+ "version": "0.2.0",
4
+ "description": "Typed WordPress REST helpers powered by Typia validation",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./client": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./http": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "dist/",
28
+ "README.md",
29
+ "package.json"
30
+ ],
31
+ "scripts": {
32
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && bun ./scripts/fix-dist-imports.mjs",
33
+ "clean": "rm -rf dist",
34
+ "test": "bun run build && bun test tests",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit"
36
+ },
37
+ "author": "imjlk",
38
+ "license": "GPL-2.0-or-later",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/imjlk/wp-typia.git",
42
+ "directory": "packages/wp-typia-rest"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/imjlk/wp-typia/issues"
46
+ },
47
+ "homepage": "https://github.com/imjlk/wp-typia/tree/main/packages/wp-typia-rest#readme",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "engines": {
52
+ "node": ">=20.0.0",
53
+ "npm": ">=10.0.0",
54
+ "bun": ">=1.3.10"
55
+ },
56
+ "dependencies": {
57
+ "@typia/interface": "^12.0.1",
58
+ "@wordpress/api-fetch": "^7.42.0"
59
+ },
60
+ "devDependencies": {
61
+ "typescript": "^5.9.2"
62
+ }
63
+ }