@loutrejs/loutre 0.4.0 → 0.5.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.
@@ -1,5 +1,7 @@
1
- import { childPipelineOf, protocolGroup } from '../core/index.js';
2
- import { createHttpDispatchKey, parseHttpPath, } from './path.js';
1
+ import { childPipelineOf } from '../core/index.js';
2
+ import { contractNodeBinding, contractNodeMetadata, protocolNamespaceBuilder, protocolNamespaceType, } from '../core/contract-internal.js';
3
+ import { httpNodeMetadata } from './internal.js';
4
+ import { assertValidHttpMethod, createHttpDispatchKey, parseHttpPath, } from './path.js';
3
5
  const controller = Object.freeze({
4
6
  kind: 'terminal',
5
7
  name: 'http.controller',
@@ -7,6 +9,14 @@ const controller = Object.freeze({
7
9
  protocol: 'http',
8
10
  });
9
11
  function defineHttp(definition) {
12
+ assertValidHttpMethod(definition.method);
13
+ for (const response of Object.values(definition.responses)) {
14
+ if (!Number.isInteger(response.status) ||
15
+ response.status < 200 ||
16
+ response.status > 599) {
17
+ throw new Error(`Invalid HTTP response status: ${response.status}`);
18
+ }
19
+ }
10
20
  const segments = parseHttpPath(definition.path);
11
21
  const paramsSchemas = definition.request?.params;
12
22
  if (paramsSchemas) {
@@ -38,7 +48,238 @@ function hasParamsValidation(pipeline) {
38
48
  hasParamsValidation(childPipelineOf(item) ?? [])));
39
49
  }
40
50
  function defineHttpGroup(definitions) {
41
- return protocolGroup('http', Object.fromEntries(Object.entries(definitions).map(([name, definition]) => [name, defineHttp(definition)])));
51
+ const resolution = resolveHttpTree(definitions, {
52
+ path: '',
53
+ pipeline: [],
54
+ responses: {},
55
+ procedurePrefix: '',
56
+ });
57
+ const group = {
58
+ kind: 'protocol-group',
59
+ protocol: 'http',
60
+ procedures: Object.freeze({ ...resolution.procedures }),
61
+ };
62
+ Object.defineProperty(group, protocolNamespaceBuilder, {
63
+ enumerable: false,
64
+ value: (root) => buildHttpNamespace(resolution.tree, root, []),
65
+ });
66
+ return Object.freeze(group);
67
+ }
68
+ const HTTP_LEAF_PROPERTIES = new Set([
69
+ 'method',
70
+ 'path',
71
+ 'summary',
72
+ 'description',
73
+ 'tags',
74
+ 'deprecated',
75
+ 'request',
76
+ 'responses',
77
+ 'pipeline',
78
+ 'interaction',
79
+ ]);
80
+ const HTTP_BRANCH_PROPERTIES = new Set([
81
+ 'path',
82
+ 'pipeline',
83
+ 'responses',
84
+ 'routes',
85
+ ]);
86
+ function assertHttpRouteTreeObject(definitions) {
87
+ const prototype = Object.getPrototypeOf(definitions);
88
+ if (prototype !== Object.prototype && prototype !== null) {
89
+ throw new Error('HTTP route tree must be a plain object');
90
+ }
91
+ }
92
+ function assertHttpRouteName(name) {
93
+ if (name.length === 0 ||
94
+ name === '__proto__' ||
95
+ name.includes('.') ||
96
+ Number.isFinite(Number(name))) {
97
+ throw new Error(`Invalid HTTP Contract node name: ${JSON.stringify(name)}`);
98
+ }
99
+ }
100
+ function assertHttpNodeProperties(source) {
101
+ const allowed = 'method' in source ? HTTP_LEAF_PROPERTIES : HTTP_BRANCH_PROPERTIES;
102
+ for (const property of Object.keys(source)) {
103
+ if (!allowed.has(property)) {
104
+ throw new Error(`Unknown HTTP Contract node property: ${property}`);
105
+ }
106
+ }
107
+ }
108
+ function resolveHttpTree(definitions, context) {
109
+ assertHttpRouteTreeObject(definitions);
110
+ const procedures = {};
111
+ const tree = {};
112
+ for (const [name, candidate] of Object.entries(definitions)) {
113
+ assertHttpRouteName(name);
114
+ const metadata = typeof candidate === 'object' &&
115
+ candidate !== null &&
116
+ httpNodeMetadata in candidate
117
+ ? candidate[httpNodeMetadata]
118
+ : undefined;
119
+ const source = (metadata?.source ?? candidate);
120
+ assertHttpNodeProperties(source);
121
+ const procedureName = context.procedurePrefix === ''
122
+ ? name
123
+ : `${context.procedurePrefix}.${name}`;
124
+ if ('method' in source) {
125
+ const responses = mergeHttpResponses(context.responses, source.responses);
126
+ const definition = {
127
+ ...source,
128
+ path: joinHttpPath(context.path, source.path),
129
+ responses,
130
+ pipeline: [...context.pipeline, ...source.pipeline],
131
+ };
132
+ const protocol = defineHttp(definition);
133
+ procedures[procedureName] = protocol;
134
+ tree[name] = {
135
+ kind: 'leaf',
136
+ source,
137
+ handlerName: metadata?.kind === 'leaf' && metadata.handlerName
138
+ ? metadata.handlerName
139
+ : name,
140
+ protocol,
141
+ };
142
+ continue;
143
+ }
144
+ assertBranchPipeline(source.pipeline ?? []);
145
+ const branchResponses = mergeHttpResponses(context.responses, source.responses ?? {});
146
+ const branch = resolveHttpTree(source.routes, {
147
+ path: joinHttpPath(context.path, source.path ?? ''),
148
+ pipeline: [...context.pipeline, ...(source.pipeline ?? [])],
149
+ responses: branchResponses,
150
+ procedurePrefix: procedureName,
151
+ });
152
+ Object.assign(procedures, branch.procedures);
153
+ tree[name] = {
154
+ kind: 'branch',
155
+ source,
156
+ children: branch.tree,
157
+ };
158
+ }
159
+ return { procedures, tree: Object.freeze(tree) };
160
+ }
161
+ function joinHttpPath(parent, child) {
162
+ if (parent === '')
163
+ return child;
164
+ if (child === '' || child === '/')
165
+ return parent;
166
+ return `${parent}${child}`;
167
+ }
168
+ function mergeHttpResponses(parent, local) {
169
+ for (const name of Object.keys(local)) {
170
+ if (Object.hasOwn(parent, name)) {
171
+ throw new Error(`Duplicate inherited HTTP response variant: ${name}`);
172
+ }
173
+ }
174
+ return Object.freeze({ ...parent, ...local });
175
+ }
176
+ function assertBranchPipeline(pipeline) {
177
+ for (const item of pipeline) {
178
+ if (item.kind === 'terminal') {
179
+ throw new Error('HTTP branch pipeline must not contain a terminal layer');
180
+ }
181
+ if (item.kind === 'layer') {
182
+ const child = childPipelineOf(item);
183
+ if (child)
184
+ assertBranchPipeline(child);
185
+ }
186
+ }
187
+ }
188
+ function buildHttpNamespace(tree, root, parentPath) {
189
+ const namespace = {};
190
+ for (const [name, template] of Object.entries(tree)) {
191
+ namespace[name] = buildHttpNode(template, root, [...parentPath, name]);
192
+ }
193
+ return Object.freeze(namespace);
194
+ }
195
+ function buildHttpNode(template, root, path) {
196
+ if (template.kind === 'leaf') {
197
+ const binding = {
198
+ kind: 'contract',
199
+ procedures: Object.freeze({
200
+ [template.handlerName]: Object.freeze({
201
+ kind: 'procedure',
202
+ protocols: Object.freeze({ http: template.protocol }),
203
+ }),
204
+ }),
205
+ };
206
+ Object.defineProperty(binding, contractNodeMetadata, {
207
+ enumerable: false,
208
+ value: Object.freeze({
209
+ kind: 'leaf',
210
+ root,
211
+ path: Object.freeze(['http', ...path]),
212
+ procedures: Object.freeze({
213
+ [template.handlerName]: path.join('.'),
214
+ }),
215
+ }),
216
+ });
217
+ Object.freeze(binding);
218
+ const node = {};
219
+ Object.defineProperty(node, contractNodeBinding, {
220
+ enumerable: false,
221
+ value: binding,
222
+ });
223
+ Object.defineProperty(node, httpNodeMetadata, {
224
+ enumerable: false,
225
+ value: Object.freeze({
226
+ kind: 'leaf',
227
+ root,
228
+ path: Object.freeze([...path]),
229
+ source: template.source,
230
+ handlerName: template.handlerName,
231
+ }),
232
+ });
233
+ return Object.freeze(node);
234
+ }
235
+ const children = buildHttpNamespace(template.children, root, path);
236
+ const procedures = {};
237
+ collectHttpNodeProcedures(template.children, '', procedures);
238
+ const binding = {
239
+ kind: 'contract',
240
+ procedures: Object.freeze(procedures),
241
+ };
242
+ Object.defineProperty(binding, contractNodeMetadata, {
243
+ enumerable: false,
244
+ value: Object.freeze({
245
+ kind: 'branch',
246
+ root,
247
+ path: Object.freeze(['http', ...path]),
248
+ procedures: Object.freeze(Object.fromEntries(Object.keys(procedures).map((procedure) => [
249
+ procedure,
250
+ [...path, procedure].join('.'),
251
+ ]))),
252
+ }),
253
+ });
254
+ Object.freeze(binding);
255
+ const node = { ...children };
256
+ Object.defineProperty(node, contractNodeBinding, {
257
+ enumerable: false,
258
+ value: binding,
259
+ });
260
+ Object.defineProperty(node, httpNodeMetadata, {
261
+ enumerable: false,
262
+ value: Object.freeze({
263
+ kind: 'branch',
264
+ root,
265
+ path: Object.freeze([...path]),
266
+ source: template.source,
267
+ }),
268
+ });
269
+ return Object.freeze(node);
270
+ }
271
+ function collectHttpNodeProcedures(tree, prefix, procedures) {
272
+ for (const [name, template] of Object.entries(tree)) {
273
+ const path = prefix === '' ? name : `${prefix}.${name}`;
274
+ if (template.kind === 'leaf') {
275
+ procedures[path] = Object.freeze({
276
+ kind: 'procedure',
277
+ protocols: Object.freeze({ http: template.protocol }),
278
+ });
279
+ continue;
280
+ }
281
+ collectHttpNodeProcedures(template.children, path, procedures);
282
+ }
42
283
  }
43
284
  export const http = Object.assign(defineHttpGroup, {
44
285
  protocol: 'http',
@@ -0,0 +1,8 @@
1
+ export declare const httpNodeMetadata: unique symbol;
2
+ export interface HttpNodeRuntimeMetadata {
3
+ readonly kind: 'leaf' | 'branch';
4
+ readonly root: object;
5
+ readonly path: readonly string[];
6
+ readonly source: object;
7
+ readonly handlerName?: string;
8
+ }
@@ -0,0 +1 @@
1
+ export const httpNodeMetadata = Symbol('loutre.http-node-metadata');
@@ -3,6 +3,9 @@ type AsciiLetter = AsciiLowercase | Uppercase<AsciiLowercase>;
3
3
  type AsciiDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
4
4
  type ParamNameStart = AsciiLetter | '_';
5
5
  type ParamNameContinuation = ParamNameStart | AsciiDigit;
6
+ type HttpMethodTokenCharacter = AsciiLetter | AsciiDigit | '!' | '#' | '$' | '%' | '&' | "'" | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~';
7
+ type IsHttpMethodTokenRest<TMethod extends string> = TMethod extends '' ? true : TMethod extends `${HttpMethodTokenCharacter}${infer TRest}` ? IsHttpMethodTokenRest<TRest> : false;
8
+ export type IsValidHttpMethod<TMethod extends string> = string extends TMethod ? false : TMethod extends `${HttpMethodTokenCharacter}${infer TRest}` ? IsHttpMethodTokenRest<TRest> : false;
6
9
  type IsParamNameRest<TName extends string> = TName extends '' ? true : TName extends `${ParamNameContinuation}${infer TRest}` ? IsParamNameRest<TRest> : false;
7
10
  type IsParamName<TName extends string> = TName extends `${ParamNameStart}${infer TRest}` ? IsParamNameRest<TRest> : false;
8
11
  type IsStaticSegment<TSegment extends string> = TSegment extends '' ? false : TSegment extends `${string}{${string}` | `${string}}${string}` ? false : TSegment extends `${string}?${string}` | `${string}#${string}` ? false : true;
@@ -43,6 +46,7 @@ export type HttpPathSegment = {
43
46
  readonly kind: 'param';
44
47
  readonly name: string;
45
48
  };
49
+ export declare function assertValidHttpMethod(method: string): void;
46
50
  export declare function parseHttpPath(path: string): readonly HttpPathSegment[];
47
51
  export declare function normalizeHttpPath(segments: readonly HttpPathSegment[]): string;
48
52
  export declare function createHttpDispatchKey(method: string, segments: readonly HttpPathSegment[]): string;
package/dist/http/path.js CHANGED
@@ -1,4 +1,10 @@
1
1
  const PARAM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
+ const HTTP_METHOD_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
3
+ export function assertValidHttpMethod(method) {
4
+ if (!HTTP_METHOD_PATTERN.test(method)) {
5
+ throw new Error(`Invalid HTTP method: ${JSON.stringify(method)}`);
6
+ }
7
+ }
2
8
  export function parseHttpPath(path) {
3
9
  if (path === '/')
4
10
  return Object.freeze([]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loutrejs/loutre",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [