@potentiajs/core 0.1.0-preview.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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/bin/potentia.js +14 -0
- package/examples/file-routing-basic/README.md +156 -0
- package/examples/file-routing-basic/app.js +16 -0
- package/examples/file-routing-basic/generate.js +21 -0
- package/examples/file-routing-basic/routes/health.js +3 -0
- package/examples/file-routing-basic/routes/index.js +3 -0
- package/examples/file-routing-basic/routes/users/[id].js +6 -0
- package/examples/file-routing-basic/routes/users/_routes.js +15 -0
- package/examples/form-rendering-basic/README.md +43 -0
- package/examples/form-rendering-basic/index.js +120 -0
- package/examples/full-flow-basic/README.md +141 -0
- package/examples/full-flow-basic/app.js +16 -0
- package/examples/full-flow-basic/form.js +205 -0
- package/examples/full-flow-basic/generate.js +21 -0
- package/examples/full-flow-basic/routes/index.js +5 -0
- package/examples/full-flow-basic/routes/users/[id].js +5 -0
- package/examples/full-flow-basic/routes/users/_routes.js +8 -0
- package/examples/full-flow-basic/routes/users/index.js +5 -0
- package/examples/full-flow-basic/routes/users/new.js +5 -0
- package/package.json +90 -0
- package/src/cli.js +407 -0
- package/src/dev/file-routing/diagnostics.js +24 -0
- package/src/dev/file-routing/generator.js +126 -0
- package/src/dev/file-routing/index.js +5 -0
- package/src/dev/file-routing/path-mapping.js +125 -0
- package/src/dev/file-routing/scanner.js +154 -0
- package/src/dev/file-routing/writer.js +100 -0
- package/src/file-routing.d.ts +36 -0
- package/src/file-routing.js +1 -0
- package/src/forms.d.ts +9 -0
- package/src/forms.js +334 -0
- package/src/index.d.ts +185 -0
- package/src/index.js +15 -0
- package/src/index.mjs +25 -0
- package/src/kernel/action-projection.js +35 -0
- package/src/kernel/action.js +172 -0
- package/src/kernel/app.js +144 -0
- package/src/kernel/context.js +37 -0
- package/src/kernel/contract-projection.js +129 -0
- package/src/kernel/contract.js +135 -0
- package/src/kernel/diagnostics.js +147 -0
- package/src/kernel/effect.js +119 -0
- package/src/kernel/error.js +93 -0
- package/src/kernel/form-projection.js +251 -0
- package/src/kernel/form-state.js +173 -0
- package/src/kernel/plugin.js +46 -0
- package/src/kernel/response.js +187 -0
- package/src/kernel/result.js +45 -0
- package/src/kernel/route-collection.js +148 -0
- package/src/kernel/route-id.js +11 -0
- package/src/kernel/route-manifest.js +129 -0
- package/src/kernel/route-projection.js +139 -0
- package/src/kernel/route.js +156 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { applyContract, createContractFailure } from './contract.js';
|
|
2
|
+
import { createRootIssue } from './diagnostics.js';
|
|
3
|
+
import { createFrameworkError, ERROR_CODES } from './error.js';
|
|
4
|
+
import { runEffect } from './effect.js';
|
|
5
|
+
import { fail } from './result.js';
|
|
6
|
+
import { normalizeSource } from './route.js';
|
|
7
|
+
import { isRedirectResponse, replaceResponseBody, responseBody } from './response.js';
|
|
8
|
+
|
|
9
|
+
export function action(id, handler, options = {}) {
|
|
10
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
11
|
+
throw new TypeError('Action id must be a non-empty string');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!isRunnableHandler(handler)) {
|
|
15
|
+
throw new TypeError('Action handler must be a function or effect descriptor');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
kind: 'action',
|
|
20
|
+
id: id,
|
|
21
|
+
handler: handler,
|
|
22
|
+
input: Object.prototype.hasOwnProperty.call(options, 'input') ? options.input : null,
|
|
23
|
+
output: Object.prototype.hasOwnProperty.call(options, 'output') ? options.output : null,
|
|
24
|
+
meta: Object.prototype.hasOwnProperty.call(options, 'meta') ? options.meta : null,
|
|
25
|
+
source: normalizeSource(options.source)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isAction(value) {
|
|
30
|
+
return Boolean(value && typeof value === 'object' && value.kind === 'action' && typeof value.id === 'string' && Object.prototype.hasOwnProperty.call(value, 'handler'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runAction(actionValue, context, options = {}) {
|
|
34
|
+
const input = await parseActionInput(context.request);
|
|
35
|
+
|
|
36
|
+
if (options.routeOptions && options.routeOptions.body) {
|
|
37
|
+
context.body = applyActionContract(options.routeOptions.body, input, 'body', 'Request body failed contract validation', ERROR_CODES.CONTRACT_FAILED, 400);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
context.input = actionValue.input ? applyActionContract(actionValue.input, input, 'input', 'Action input contract failed.', ERROR_CODES.ACTION_INPUT_FAILED, 400) : input;
|
|
41
|
+
|
|
42
|
+
let value;
|
|
43
|
+
try {
|
|
44
|
+
value = await runEffect(actionValue.handler, context);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw createFrameworkError(ERROR_CODES.ACTION_HANDLER_FAILED, 'Internal server error', {
|
|
47
|
+
status: 500,
|
|
48
|
+
detail: { boundary: 'handler', issues: [createRootIssue({ code: 'handler_failed', message: 'Handler failed', boundary: 'handler', source: 'framework' })] },
|
|
49
|
+
cause: error,
|
|
50
|
+
expose: false
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!actionValue.output || value instanceof Response || isRedirectResponse(value) || isFailureResult(value)) return value;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const body = responseBody(value);
|
|
58
|
+
const output = applyContract(actionValue.output, body, { boundary: 'output' });
|
|
59
|
+
return replaceResponseBody(value, output);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return fail(createContractFailure('output', 'Action output contract failed.', error, {
|
|
62
|
+
code: ERROR_CODES.ACTION_OUTPUT_FAILED,
|
|
63
|
+
status: 500
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function parseActionInput(request) {
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = await request.text();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw createActionInputFailure('Action input could not be read.', error);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (text.length === 0) return null;
|
|
77
|
+
|
|
78
|
+
const contentType = detectActionContentType(request.headers.get('content-type'));
|
|
79
|
+
|
|
80
|
+
if (contentType === 'form-urlencoded') {
|
|
81
|
+
return parseUrlEncodedActionInput(text);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (contentType === 'unsupported') {
|
|
85
|
+
throw createActionInputFailure('Action input content type is unsupported.', null);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw createActionInputFailure('Action input JSON was malformed.', error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function detectActionContentType(value) {
|
|
96
|
+
if (typeof value !== 'string' || value.trim().length === 0) return 'missing';
|
|
97
|
+
|
|
98
|
+
const mediaType = value.toLowerCase().split(';')[0].trim();
|
|
99
|
+
if (mediaType === 'application/json') return 'json';
|
|
100
|
+
if (mediaType === 'application/x-www-form-urlencoded') return 'form-urlencoded';
|
|
101
|
+
return 'unsupported';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseUrlEncodedActionInput(text) {
|
|
105
|
+
const result = {};
|
|
106
|
+
|
|
107
|
+
for (const pair of text.split('&')) {
|
|
108
|
+
if (pair.length === 0) continue;
|
|
109
|
+
|
|
110
|
+
const separator = pair.indexOf('=');
|
|
111
|
+
const rawKey = separator === -1 ? pair : pair.slice(0, separator);
|
|
112
|
+
const rawValue = separator === -1 ? '' : pair.slice(separator + 1);
|
|
113
|
+
const key = decodeUrlEncodedPart(rawKey);
|
|
114
|
+
const value = decodeUrlEncodedPart(rawValue);
|
|
115
|
+
|
|
116
|
+
if (isDangerousFormKey(key)) {
|
|
117
|
+
throw createActionInputFailure('Action form input contained an unsafe field name.', null);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
|
121
|
+
const current = result[key];
|
|
122
|
+
result[key] = Array.isArray(current) ? current.concat(value) : [current, value];
|
|
123
|
+
} else {
|
|
124
|
+
result[key] = value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function decodeUrlEncodedPart(value) {
|
|
132
|
+
try {
|
|
133
|
+
return decodeURIComponent(value.replace(/\+/g, ' '));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw createActionInputFailure('Action form input was malformed.', error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isDangerousFormKey(key) {
|
|
140
|
+
return key === '__proto__' || key === 'constructor' || key === 'prototype';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isFailureResult(value) {
|
|
144
|
+
return Boolean(value && typeof value === 'object' && value.ok === false && 'value' in value && 'error' in value && 'meta' in value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function applyActionContract(contract, value, boundary, message, code, status) {
|
|
148
|
+
try {
|
|
149
|
+
return applyContract(contract, value, { boundary: boundary });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw createContractFailure(boundary, message, error, { code: code, status: status });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createActionInputFailure(message, cause) {
|
|
156
|
+
return createFrameworkError(ERROR_CODES.ACTION_INPUT_FAILED, message, {
|
|
157
|
+
status: 400,
|
|
158
|
+
detail: { boundary: 'input', issues: [createRootIssue({ code: actionInputIssueCode(message), message: message, boundary: 'input', source: 'framework' })] },
|
|
159
|
+
cause: cause,
|
|
160
|
+
expose: true
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function actionInputIssueCode(message) {
|
|
165
|
+
if (message.includes('malformed')) return 'malformed_input';
|
|
166
|
+
if (message.includes('unsupported')) return 'unsupported_content_type';
|
|
167
|
+
return 'input_failed';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isRunnableHandler(handler) {
|
|
171
|
+
return typeof handler === 'function' || Boolean(handler && typeof handler === 'object' && handler.type === 'effect' && typeof handler.run === 'function');
|
|
172
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { isAction, runAction } from './action.js';
|
|
2
|
+
import { applyContract, applyNamedRequestContract, createContractFailure, parseContractBody } from './contract.js';
|
|
3
|
+
import { createRequestContext } from './context.js';
|
|
4
|
+
import { createFrameworkError, ERROR_CODES, normalizeFrameworkError } from './error.js';
|
|
5
|
+
import { runEffect } from './effect.js';
|
|
6
|
+
import { pluginRoutes } from './plugin.js';
|
|
7
|
+
import { fail } from './result.js';
|
|
8
|
+
import { composeRoutes, normalizeHooks } from './route-collection.js';
|
|
9
|
+
import { matchRoute } from './route.js';
|
|
10
|
+
import { isRedirectResponse, isResponseDescriptor, replaceResponseBody, responseBody, toResponse } from './response.js';
|
|
11
|
+
|
|
12
|
+
export function createApp(options = {}) {
|
|
13
|
+
const state = Object.prototype.hasOwnProperty.call(options, 'state') ? options.state : null;
|
|
14
|
+
const pluginRouteCollections = pluginRoutes(options.plugins, { state: state });
|
|
15
|
+
const routes = composeRoutes((Array.isArray(options.routes) ? options.routes : []).concat(pluginRouteCollections));
|
|
16
|
+
const hooks = normalizeHooks(options.hooks);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
routes: routes,
|
|
20
|
+
state: state,
|
|
21
|
+
hooks: hooks,
|
|
22
|
+
fetch: async function fetch(request) {
|
|
23
|
+
return handleRequest(request, routes, state, hooks);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function handleRequest(request, routes, state, hooks) {
|
|
29
|
+
const url = new URL(request.url);
|
|
30
|
+
const match = matchRoute(routes, request.method, url.pathname);
|
|
31
|
+
|
|
32
|
+
if (match.error) {
|
|
33
|
+
return toResponse(fail(match.error));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!match.found) {
|
|
37
|
+
if (!match.methodAllowed) {
|
|
38
|
+
return methodNotAllowedResponse(match.allowed);
|
|
39
|
+
}
|
|
40
|
+
return toResponse(fail(createFrameworkError(ERROR_CODES.NOT_FOUND, 'No route matched the request path', { status: 404, expose: true })));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const context = createRequestContext(request, match, state);
|
|
44
|
+
const routeHooks = normalizeHooks(match.route.hooks);
|
|
45
|
+
const beforeHooks = hooks.beforeRequest.concat(routeHooks.beforeRequest);
|
|
46
|
+
const afterHooks = routeHooks.afterResponse.concat(hooks.afterResponse);
|
|
47
|
+
const errorHooks = routeHooks.onError.concat(hooks.onError);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const before = await runBeforeRequestHooks(beforeHooks, context);
|
|
51
|
+
if (before !== undefined) {
|
|
52
|
+
return await projectAndRunAfterHooks(before, afterHooks, context);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const actionRoute = isAction(match.route.handler);
|
|
56
|
+
await applyRequestContracts(context, match.route.options, { body: !actionRoute });
|
|
57
|
+
|
|
58
|
+
const value = actionRoute ? await runAction(match.route.handler, context, { routeOptions: match.route.options }) : await runEffect(match.route.handler, context);
|
|
59
|
+
const contracted = applyResponseContract(value, match.route.options);
|
|
60
|
+
return await projectAndRunAfterHooks(contracted, afterHooks, context);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return handleError(error, errorHooks, context);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function applyRequestContracts(context, options, config = {}) {
|
|
67
|
+
context.params = applyNamedRequestContract('Params', options.params, context.params);
|
|
68
|
+
context.query = applyNamedRequestContract('Query', options.query, context.query);
|
|
69
|
+
context.headers = applyNamedRequestContract('Headers', options.headers, context.headers);
|
|
70
|
+
|
|
71
|
+
if (options.body && config.body !== false) {
|
|
72
|
+
try {
|
|
73
|
+
context.body = await parseContractBody(context.request, options.body);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyResponseContract(value, options) {
|
|
81
|
+
if (!options.response) return value;
|
|
82
|
+
if (value instanceof Response) return value;
|
|
83
|
+
if (isRedirectResponse(value)) return value;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const body = responseBody(value);
|
|
87
|
+
const contractedBody = applyContract(options.response, body);
|
|
88
|
+
return replaceResponseBody(value, contractedBody);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return fail(createContractFailure('response', 'Response failed contract validation', error, {
|
|
91
|
+
code: ERROR_CODES.RESPONSE_CONTRACT_FAILED,
|
|
92
|
+
status: 500
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runBeforeRequestHooks(hooks, context) {
|
|
98
|
+
for (const hook of hooks) {
|
|
99
|
+
const value = await runEffect(hook, context);
|
|
100
|
+
if (isHookResponse(value)) return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function projectAndRunAfterHooks(value, hooks, context) {
|
|
107
|
+
let response = toResponse(value);
|
|
108
|
+
|
|
109
|
+
for (const hook of hooks) {
|
|
110
|
+
const replacement = await runEffect(hook, context, response);
|
|
111
|
+
if (isHookResponse(replacement)) response = toResponse(replacement);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return response;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function handleError(error, hooks, context) {
|
|
118
|
+
const normalized = normalizeFrameworkError(error);
|
|
119
|
+
|
|
120
|
+
for (const hook of hooks) {
|
|
121
|
+
try {
|
|
122
|
+
const replacement = await runEffect(hook, context, normalized);
|
|
123
|
+
if (isHookResponse(replacement)) return toResponse(replacement);
|
|
124
|
+
} catch (hookError) {
|
|
125
|
+
return toResponse(hookError);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return toResponse(fail(normalized));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function methodNotAllowedResponse(allowed) {
|
|
133
|
+
const response = toResponse(fail(createFrameworkError(ERROR_CODES.METHOD_NOT_ALLOWED, 'Route matched the path, but not the request method', { status: 405, expose: true })));
|
|
134
|
+
response.headers.set('allow', allowed.join(', '));
|
|
135
|
+
return response;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isHookResponse(value) {
|
|
139
|
+
return value instanceof Response || isResponseDescriptor(value) || isResult(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isResult(value) {
|
|
143
|
+
return Boolean(value && typeof value === 'object' && typeof value.ok === 'boolean' && 'value' in value && 'error' in value && 'meta' in value);
|
|
144
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function createRequestContext(request, match, appState = null) {
|
|
2
|
+
const url = new URL(request.url);
|
|
3
|
+
|
|
4
|
+
return {
|
|
5
|
+
request: request,
|
|
6
|
+
method: request.method,
|
|
7
|
+
url: url,
|
|
8
|
+
path: url.pathname,
|
|
9
|
+
params: match.params || {},
|
|
10
|
+
query: queryObject(url.searchParams),
|
|
11
|
+
headers: headersObject(request.headers),
|
|
12
|
+
body: null,
|
|
13
|
+
route: match.route,
|
|
14
|
+
state: appState
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function queryObject(searchParams) {
|
|
19
|
+
const grouped = {};
|
|
20
|
+
|
|
21
|
+
for (const key of Array.from(searchParams.keys()).sort()) {
|
|
22
|
+
const values = searchParams.getAll(key);
|
|
23
|
+
grouped[key] = values.length > 1 ? values : values[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return grouped;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headersObject(headers) {
|
|
30
|
+
const plain = {};
|
|
31
|
+
|
|
32
|
+
for (const [key, value] of Array.from(headers.entries()).sort(([a], [b]) => a.localeCompare(b))) {
|
|
33
|
+
plain[key.toLowerCase()] = value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return plain;
|
|
37
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { normalizeContract } from './contract.js';
|
|
2
|
+
|
|
3
|
+
export function projectContract(contract) {
|
|
4
|
+
const adapter = normalizeContract(contract);
|
|
5
|
+
const rawProjection = adapter.project();
|
|
6
|
+
const kind = normalizeKind(rawProjection.kind);
|
|
7
|
+
const capabilities = normalizeCapabilities(rawProjection.capabilities);
|
|
8
|
+
const schema = projectSchema(adapter.source, kind);
|
|
9
|
+
const fields = projectFields(adapter.source, kind, schema);
|
|
10
|
+
const required = projectRequired(adapter.source, kind, schema, fields);
|
|
11
|
+
const optional = fields && required ? fields.map((field) => field.name).filter((field) => !required.includes(field)) : null;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
kind: kind,
|
|
15
|
+
capabilities: capabilities,
|
|
16
|
+
capability: capabilityObject(capabilities),
|
|
17
|
+
opaque: kind !== 'sigil',
|
|
18
|
+
schema: schema,
|
|
19
|
+
fields: fields,
|
|
20
|
+
required: required,
|
|
21
|
+
optional: optional,
|
|
22
|
+
meta: null
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeKind(kind) {
|
|
27
|
+
if (kind === 'generic-function') return 'function';
|
|
28
|
+
if (kind === 'generic-parse') return 'parse';
|
|
29
|
+
if (kind === 'generic-check') return 'check';
|
|
30
|
+
if (kind === 'sigil') return 'sigil';
|
|
31
|
+
if (kind === 'none') return 'none';
|
|
32
|
+
return 'unknown';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeCapabilities(capabilities) {
|
|
36
|
+
return Array.isArray(capabilities) ? capabilities.map(normalizeCapability).filter(Boolean) : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeCapability(capability) {
|
|
40
|
+
if (capability === 'safeParse' || capability === 'describe') return null;
|
|
41
|
+
if (capability === 'project') return 'project';
|
|
42
|
+
if (capability === 'parse') return 'parse';
|
|
43
|
+
if (capability === 'check') return 'check';
|
|
44
|
+
return String(capability || '') || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function capabilityObject(capabilities) {
|
|
48
|
+
return {
|
|
49
|
+
parse: capabilities.includes('parse'),
|
|
50
|
+
check: capabilities.includes('check'),
|
|
51
|
+
project: capabilities.includes('project')
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function projectSchema(contract, kind) {
|
|
56
|
+
if (kind !== 'sigil') return null;
|
|
57
|
+
|
|
58
|
+
if (contract && typeof contract.toJSONSchema === 'function') {
|
|
59
|
+
return contract.toJSONSchema();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function projectFields(contract, kind, schema) {
|
|
66
|
+
if (kind !== 'sigil') return null;
|
|
67
|
+
|
|
68
|
+
const described = describeContract(contract);
|
|
69
|
+
if (described && Array.isArray(described.properties)) {
|
|
70
|
+
return described.properties.map(projectDescribedProperty).filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (schema && schema.properties && typeof schema.properties === 'object') {
|
|
74
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
75
|
+
return Object.keys(schema.properties).map((name) => ({
|
|
76
|
+
name: name,
|
|
77
|
+
required: required.includes(name),
|
|
78
|
+
kind: typeof schema.properties[name]?.type === 'string' ? schema.properties[name].type : 'unknown',
|
|
79
|
+
fields: null
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function projectRequired(contract, kind, schema, fields) {
|
|
87
|
+
if (kind !== 'sigil') return null;
|
|
88
|
+
|
|
89
|
+
const described = describeContract(contract);
|
|
90
|
+
if (described && Array.isArray(described.properties)) {
|
|
91
|
+
return described.properties.filter((property) => property.required === true).map((property) => property.key).filter((key) => typeof key === 'string');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (schema && Array.isArray(schema.required)) {
|
|
95
|
+
return schema.required.slice();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return fields ? fields.filter((field) => field.required).map((field) => field.name) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function projectDescribedProperty(property) {
|
|
102
|
+
if (!property || typeof property.key !== 'string') return null;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: property.key,
|
|
106
|
+
required: property.required === true,
|
|
107
|
+
kind: describedKind(property.contract),
|
|
108
|
+
fields: describedFields(property.contract)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describedFields(contract) {
|
|
113
|
+
if (!contract || !Array.isArray(contract.properties)) return null;
|
|
114
|
+
return contract.properties.map(projectDescribedProperty).filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function describedKind(contract) {
|
|
118
|
+
if (!contract || typeof contract.kind !== 'string') return 'unknown';
|
|
119
|
+
return contract.kind;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function describeContract(contract) {
|
|
123
|
+
if (!contract || typeof contract.describe !== 'function') return null;
|
|
124
|
+
try {
|
|
125
|
+
return contract.describe();
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createFrameworkError, ERROR_CODES } from './error.js';
|
|
2
|
+
import { createRootIssue, sigilIssueFromError } from './diagnostics.js';
|
|
3
|
+
|
|
4
|
+
export function normalizeContract(contract) {
|
|
5
|
+
if (!contract) {
|
|
6
|
+
return createAdapter('none', contract, (value) => value, () => ({ kind: 'none', capabilities: [] }));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (typeof contract === 'function') {
|
|
10
|
+
return createAdapter('generic-function', contract, contract, () => ({ kind: 'generic-function', capabilities: ['parse'] }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (isSigilContract(contract)) {
|
|
14
|
+
return createAdapter('sigil', contract, (value) => contract.parse(value), () => projectSigilContract(contract));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (contract && typeof contract.parse === 'function') {
|
|
18
|
+
const capabilities = ['parse'];
|
|
19
|
+
if (typeof contract.check === 'function') capabilities.push('check');
|
|
20
|
+
return createAdapter('generic-parse', contract, (value, context) => contract.parse(value, context), () => ({ kind: 'generic-parse', capabilities: capabilities }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (contract && typeof contract.check === 'function') {
|
|
24
|
+
return createAdapter('generic-check', contract, (value, context) => {
|
|
25
|
+
if (!contract.check(value, context)) {
|
|
26
|
+
const error = new Error('Contract check returned false');
|
|
27
|
+
error.code = 'POTENTIA_CONTRACT_CHECK_FALSE';
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}, () => ({ kind: 'generic-check', capabilities: ['check'] }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return createAdapter('unknown', contract, (value) => value, () => ({ kind: 'unknown', capabilities: [] }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function applyContract(contract, value, context = {}) {
|
|
38
|
+
const adapter = normalizeContract(contract);
|
|
39
|
+
return adapter.parse(value, context);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function applyContractResult(contract, value, context = {}) {
|
|
43
|
+
try {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
value: applyContract(contract, value, context),
|
|
47
|
+
error: null
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
value: null,
|
|
53
|
+
error: createContractFailure(context.boundary || 'contract', 'Contract failed', error)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function parseContractBody(request, contract) {
|
|
59
|
+
if (!contract) return null;
|
|
60
|
+
|
|
61
|
+
const value = await request.json();
|
|
62
|
+
try {
|
|
63
|
+
return applyContract(contract, value, { boundary: 'body' });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw createContractFailure('body', 'Request body failed contract validation', error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function applyNamedRequestContract(name, contract, value) {
|
|
70
|
+
if (!contract) return value;
|
|
71
|
+
|
|
72
|
+
const boundary = name.toLowerCase();
|
|
73
|
+
try {
|
|
74
|
+
return applyContract(contract, value, { boundary: boundary });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw createContractFailure(boundary, `${name} failed contract validation`, error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createContractFailure(boundary, message, cause, options = {}) {
|
|
81
|
+
return createFrameworkError(options.code || ERROR_CODES.CONTRACT_FAILED, message, {
|
|
82
|
+
status: options.status || 400,
|
|
83
|
+
detail: {
|
|
84
|
+
boundary: boundary,
|
|
85
|
+
issues: safeContractIssues(cause, boundary)
|
|
86
|
+
},
|
|
87
|
+
cause: cause,
|
|
88
|
+
expose: true
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isSigilContract(contract) {
|
|
93
|
+
return Boolean(
|
|
94
|
+
contract &&
|
|
95
|
+
typeof contract === 'object' &&
|
|
96
|
+
typeof contract.parse === 'function' &&
|
|
97
|
+
typeof contract.safeParse === 'function' &&
|
|
98
|
+
typeof contract.toJSONSchema === 'function' &&
|
|
99
|
+
typeof contract.describe === 'function' &&
|
|
100
|
+
Object.prototype.hasOwnProperty.call(contract, 'ast')
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createAdapter(kind, source, parse, project) {
|
|
105
|
+
return {
|
|
106
|
+
kind: kind,
|
|
107
|
+
source: source,
|
|
108
|
+
parse: parse,
|
|
109
|
+
project: project,
|
|
110
|
+
describe: function describe() {
|
|
111
|
+
return project();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function projectSigilContract(contract) {
|
|
117
|
+
return {
|
|
118
|
+
kind: 'sigil',
|
|
119
|
+
capabilities: ['parse', 'check', 'safeParse', 'describe', 'project'],
|
|
120
|
+
name: typeof contract.name === 'string' ? contract.name : null,
|
|
121
|
+
source: typeof contract.source === 'string' ? contract.source : null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function safeContractIssues(cause, boundary) {
|
|
126
|
+
if (cause && typeof cause === 'object' && cause.code === 'SIGIL_VALIDATION_FAILED') {
|
|
127
|
+
return [sigilIssueFromError(cause, boundary)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (cause && typeof cause === 'object' && cause.code === 'POTENTIA_CONTRACT_CHECK_FALSE') {
|
|
131
|
+
return [createRootIssue({ code: 'check_failed', message: 'Contract check returned false', boundary: boundary, source: 'generic' })];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return [createRootIssue({ code: 'parse_failed', message: 'Contract parser rejected value', boundary: boundary, source: 'generic' })];
|
|
135
|
+
}
|