@beignet/core 0.0.36 → 0.0.37
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 +6 -0
- package/README.md +27 -0
- package/dist/contracts/contract-builder.d.ts +7 -0
- package/dist/contracts/contract-builder.d.ts.map +1 -1
- package/dist/contracts/contract-builder.js +22 -0
- package/dist/contracts/contract-builder.js.map +1 -1
- package/dist/contracts/contract-group.d.ts +7 -0
- package/dist/contracts/contract-group.d.ts.map +1 -1
- package/dist/contracts/contract-group.js +14 -0
- package/dist/contracts/contract-group.js.map +1 -1
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +4 -0
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/lifecycle.d.ts +40 -0
- package/dist/contracts/lifecycle.d.ts.map +1 -0
- package/dist/contracts/lifecycle.js +174 -0
- package/dist/contracts/lifecycle.js.map +1 -0
- package/dist/contracts/types.d.ts +5 -0
- package/dist/contracts/types.d.ts.map +1 -1
- package/dist/contracts/types.js.map +1 -1
- package/dist/openapi/index.d.ts +3 -1
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +14 -2
- package/dist/openapi/index.js.map +1 -1
- package/dist/server/request-executor.d.ts.map +1 -1
- package/dist/server/request-executor.js +3 -2
- package/dist/server/request-executor.js.map +1 -1
- package/dist/server/response-finalization.d.ts +2 -0
- package/dist/server/response-finalization.d.ts.map +1 -1
- package/dist/server/response-finalization.js +42 -0
- package/dist/server/response-finalization.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +9 -0
- package/dist/server/server.js.map +1 -1
- package/package.json +1 -1
- package/src/contracts/contract-builder.ts +38 -0
- package/src/contracts/contract-group.ts +25 -0
- package/src/contracts/index.ts +8 -0
- package/src/contracts/lifecycle.ts +236 -0
- package/src/contracts/types.ts +5 -0
- package/src/openapi/index.ts +22 -2
- package/src/server/request-executor.ts +6 -1
- package/src/server/response-finalization.ts +58 -0
- package/src/server/server.ts +14 -0
- package/dist/domain/events.d.ts +0 -44
- package/dist/domain/events.d.ts.map +0 -1
- package/dist/domain/events.js +0 -24
- package/dist/domain/events.js.map +0 -1
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
responsesFromErrors,
|
|
6
6
|
} from "./catalog-errors.js";
|
|
7
7
|
import { ContractBuilder } from "./contract-builder.js";
|
|
8
|
+
import {
|
|
9
|
+
assertValidContractDeprecation,
|
|
10
|
+
type ContractDeprecationMeta,
|
|
11
|
+
} from "./lifecycle.js";
|
|
8
12
|
import { mergeContractMeta } from "./metadata.js";
|
|
9
13
|
import { parsePathTemplate } from "./path-template.js";
|
|
10
14
|
import type {
|
|
@@ -164,6 +168,27 @@ export class ContractGroup<
|
|
|
164
168
|
});
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Mark every contract created from this group as deprecated.
|
|
173
|
+
*/
|
|
174
|
+
deprecated<const TDeprecation extends ContractDeprecationMeta>(
|
|
175
|
+
deprecation: TDeprecation,
|
|
176
|
+
): ContractGroup<
|
|
177
|
+
TSharedResponses,
|
|
178
|
+
MergeContractMeta<TSharedMeta, { deprecation: TDeprecation }>,
|
|
179
|
+
TSharedHeaders,
|
|
180
|
+
TPathPrefix
|
|
181
|
+
> {
|
|
182
|
+
assertValidContractDeprecation(deprecation, this._namespace || "group");
|
|
183
|
+
return new ContractGroup({
|
|
184
|
+
namespace: this._namespace,
|
|
185
|
+
meta: mergeContractMeta(this._meta, { deprecation }),
|
|
186
|
+
responses: this._responses,
|
|
187
|
+
headers: this._headers,
|
|
188
|
+
pathPrefix: this._pathPrefix,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
/**
|
|
168
193
|
* Add shared route-owned response schemas to contracts created from this group.
|
|
169
194
|
*
|
package/src/contracts/index.ts
CHANGED
|
@@ -35,6 +35,14 @@ export {
|
|
|
35
35
|
type ResolveContract,
|
|
36
36
|
resolveContract,
|
|
37
37
|
} from "./contract-like.js";
|
|
38
|
+
/**
|
|
39
|
+
* Contract lifecycle metadata and errors.
|
|
40
|
+
*/
|
|
41
|
+
export {
|
|
42
|
+
type ContractDeprecationMeta,
|
|
43
|
+
ContractLifecycleError,
|
|
44
|
+
type ContractLifecycleFindingCode,
|
|
45
|
+
} from "./lifecycle.js";
|
|
38
46
|
/**
|
|
39
47
|
* OpenAPI operation metadata type.
|
|
40
48
|
*/
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { HttpContractConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle metadata for an HTTP contract that external clients should stop
|
|
5
|
+
* using.
|
|
6
|
+
*/
|
|
7
|
+
export type ContractDeprecationMeta = {
|
|
8
|
+
/** UTC ISO 8601 timestamp when the contract became deprecated. */
|
|
9
|
+
since: string;
|
|
10
|
+
/** Optional human-readable explanation. */
|
|
11
|
+
reason?: string;
|
|
12
|
+
/** UTC ISO 8601 timestamp after which the contract may stop being served. */
|
|
13
|
+
sunset?: string;
|
|
14
|
+
/** URI reference for the preferred replacement operation. */
|
|
15
|
+
replacement?: string;
|
|
16
|
+
/** Absolute HTTP(S) URL with migration or deprecation documentation. */
|
|
17
|
+
documentation?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Stable code identifying invalid lifecycle or operation metadata. */
|
|
21
|
+
export type ContractLifecycleFindingCode =
|
|
22
|
+
| "INVALID_DEPRECATION_SINCE"
|
|
23
|
+
| "INVALID_DEPRECATION_SUNSET"
|
|
24
|
+
| "DEPRECATION_SUNSET_BEFORE_SINCE"
|
|
25
|
+
| "INVALID_DEPRECATION_REASON"
|
|
26
|
+
| "INVALID_DEPRECATION_REPLACEMENT"
|
|
27
|
+
| "INVALID_DEPRECATION_DOCUMENTATION"
|
|
28
|
+
| "INVALID_OPERATION_ID";
|
|
29
|
+
|
|
30
|
+
/** Error raised when contract lifecycle metadata is malformed. */
|
|
31
|
+
export class ContractLifecycleError extends Error {
|
|
32
|
+
/** Stable machine-readable finding code. */
|
|
33
|
+
readonly code: ContractLifecycleFindingCode;
|
|
34
|
+
/** Name of the invalid contract, or the group label during group setup. */
|
|
35
|
+
readonly contract: string;
|
|
36
|
+
|
|
37
|
+
constructor(args: {
|
|
38
|
+
code: ContractLifecycleFindingCode;
|
|
39
|
+
contract: string;
|
|
40
|
+
message: string;
|
|
41
|
+
}) {
|
|
42
|
+
super(args.message);
|
|
43
|
+
this.name = "ContractLifecycleError";
|
|
44
|
+
this.code = args.code;
|
|
45
|
+
this.contract = args.contract;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ISO_8601_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
|
50
|
+
const INVALID_URI_REFERENCE_CHARACTERS = /[\s<>"\\]/;
|
|
51
|
+
const INVALID_PERCENT_ENCODING = /%(?![0-9A-Fa-f]{2})/;
|
|
52
|
+
|
|
53
|
+
function parseUtcTimestamp(value: string): number | undefined {
|
|
54
|
+
if (!ISO_8601_UTC.test(value)) return undefined;
|
|
55
|
+
const timestamp = Date.parse(value);
|
|
56
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
57
|
+
|
|
58
|
+
const canonical = new Date(timestamp).toISOString();
|
|
59
|
+
if (value !== canonical && value !== canonical.replace(".000Z", "Z")) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return timestamp;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertNonEmptyOptionalString(args: {
|
|
67
|
+
value: unknown;
|
|
68
|
+
field: "reason" | "replacement";
|
|
69
|
+
code: ContractLifecycleFindingCode;
|
|
70
|
+
contract: string;
|
|
71
|
+
}): string | undefined {
|
|
72
|
+
if (args.value === undefined) return undefined;
|
|
73
|
+
if (typeof args.value !== "string" || args.value.trim().length === 0) {
|
|
74
|
+
throw new ContractLifecycleError({
|
|
75
|
+
code: args.code,
|
|
76
|
+
contract: args.contract,
|
|
77
|
+
message: `Contract "${args.contract}" deprecation ${args.field} must be a non-empty string.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return args.value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Validate deprecation metadata supplied by builders or raw contract configs. */
|
|
84
|
+
export function assertValidContractDeprecation(
|
|
85
|
+
deprecation: unknown,
|
|
86
|
+
contract: string,
|
|
87
|
+
): asserts deprecation is ContractDeprecationMeta {
|
|
88
|
+
if (typeof deprecation !== "object" || deprecation === null) {
|
|
89
|
+
throw new ContractLifecycleError({
|
|
90
|
+
code: "INVALID_DEPRECATION_SINCE",
|
|
91
|
+
contract,
|
|
92
|
+
message: `Contract "${contract}" deprecation metadata must include a valid "since" timestamp.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const metadata = deprecation as Record<string, unknown>;
|
|
97
|
+
const since =
|
|
98
|
+
typeof metadata.since === "string"
|
|
99
|
+
? parseUtcTimestamp(metadata.since)
|
|
100
|
+
: undefined;
|
|
101
|
+
if (since === undefined) {
|
|
102
|
+
throw new ContractLifecycleError({
|
|
103
|
+
code: "INVALID_DEPRECATION_SINCE",
|
|
104
|
+
contract,
|
|
105
|
+
message: `Contract "${contract}" deprecation "since" must be a valid UTC ISO 8601 timestamp such as 2026-07-11T00:00:00Z.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let sunset: number | undefined;
|
|
110
|
+
if (metadata.sunset !== undefined) {
|
|
111
|
+
sunset =
|
|
112
|
+
typeof metadata.sunset === "string"
|
|
113
|
+
? parseUtcTimestamp(metadata.sunset)
|
|
114
|
+
: undefined;
|
|
115
|
+
if (sunset === undefined) {
|
|
116
|
+
throw new ContractLifecycleError({
|
|
117
|
+
code: "INVALID_DEPRECATION_SUNSET",
|
|
118
|
+
contract,
|
|
119
|
+
message: `Contract "${contract}" deprecation "sunset" must be a valid UTC ISO 8601 timestamp.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (sunset < since) {
|
|
123
|
+
throw new ContractLifecycleError({
|
|
124
|
+
code: "DEPRECATION_SUNSET_BEFORE_SINCE",
|
|
125
|
+
contract,
|
|
126
|
+
message: `Contract "${contract}" deprecation "sunset" must not be earlier than "since".`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
assertNonEmptyOptionalString({
|
|
132
|
+
value: metadata.reason,
|
|
133
|
+
field: "reason",
|
|
134
|
+
code: "INVALID_DEPRECATION_REASON",
|
|
135
|
+
contract,
|
|
136
|
+
});
|
|
137
|
+
const replacement = assertNonEmptyOptionalString({
|
|
138
|
+
value: metadata.replacement,
|
|
139
|
+
field: "replacement",
|
|
140
|
+
code: "INVALID_DEPRECATION_REPLACEMENT",
|
|
141
|
+
contract,
|
|
142
|
+
});
|
|
143
|
+
let replacementIsValid = true;
|
|
144
|
+
if (replacement) {
|
|
145
|
+
try {
|
|
146
|
+
new URL(replacement, "https://beignet.invalid");
|
|
147
|
+
} catch {
|
|
148
|
+
replacementIsValid = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
replacement &&
|
|
153
|
+
(!replacementIsValid ||
|
|
154
|
+
INVALID_URI_REFERENCE_CHARACTERS.test(replacement) ||
|
|
155
|
+
INVALID_PERCENT_ENCODING.test(replacement))
|
|
156
|
+
) {
|
|
157
|
+
throw new ContractLifecycleError({
|
|
158
|
+
code: "INVALID_DEPRECATION_REPLACEMENT",
|
|
159
|
+
contract,
|
|
160
|
+
message: `Contract "${contract}" deprecation "replacement" must be a valid URI reference without whitespace.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (metadata.documentation !== undefined) {
|
|
165
|
+
let documentation: URL | undefined;
|
|
166
|
+
if (typeof metadata.documentation === "string") {
|
|
167
|
+
try {
|
|
168
|
+
documentation = new URL(metadata.documentation);
|
|
169
|
+
} catch {
|
|
170
|
+
documentation = undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
!documentation ||
|
|
175
|
+
(documentation.protocol !== "http:" &&
|
|
176
|
+
documentation.protocol !== "https:") ||
|
|
177
|
+
INVALID_URI_REFERENCE_CHARACTERS.test(metadata.documentation as string)
|
|
178
|
+
) {
|
|
179
|
+
throw new ContractLifecycleError({
|
|
180
|
+
code: "INVALID_DEPRECATION_DOCUMENTATION",
|
|
181
|
+
contract,
|
|
182
|
+
message: `Contract "${contract}" deprecation "documentation" must be an absolute HTTP(S) URL.`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Return and validate the operation ID used by OpenAPI and route registries. */
|
|
189
|
+
export function getContractOperationId(
|
|
190
|
+
contract: Pick<HttpContractConfig, "name" | "metadata">,
|
|
191
|
+
): string {
|
|
192
|
+
const operationId = contract.metadata.openapi?.operationId ?? contract.name;
|
|
193
|
+
if (
|
|
194
|
+
typeof operationId !== "string" ||
|
|
195
|
+
operationId.trim().length === 0 ||
|
|
196
|
+
operationId !== operationId.trim()
|
|
197
|
+
) {
|
|
198
|
+
throw new ContractLifecycleError({
|
|
199
|
+
code: "INVALID_OPERATION_ID",
|
|
200
|
+
contract: contract.name,
|
|
201
|
+
message: `Contract "${contract.name}" operationId must be a non-empty string without surrounding whitespace.`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return operationId;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Validate lifecycle metadata on a complete contract definition. */
|
|
208
|
+
export function assertValidContractLifecycle(
|
|
209
|
+
contract: Pick<HttpContractConfig, "name" | "metadata">,
|
|
210
|
+
): void {
|
|
211
|
+
getContractOperationId(contract);
|
|
212
|
+
const deprecation = contract.metadata.deprecation;
|
|
213
|
+
if (deprecation !== undefined) {
|
|
214
|
+
assertValidContractDeprecation(deprecation, contract.name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Build standards-based HTTP response headers for a deprecated contract. */
|
|
219
|
+
export function contractLifecycleResponseHeaders(
|
|
220
|
+
contract: Pick<HttpContractConfig, "name" | "metadata">,
|
|
221
|
+
): Record<string, string> {
|
|
222
|
+
const deprecation = contract.metadata.deprecation;
|
|
223
|
+
if (deprecation === undefined) return {};
|
|
224
|
+
assertValidContractDeprecation(deprecation, contract.name);
|
|
225
|
+
|
|
226
|
+
const headers: Record<string, string> = {
|
|
227
|
+
Deprecation: `@${Math.floor(Date.parse(deprecation.since) / 1000)}`,
|
|
228
|
+
};
|
|
229
|
+
if (deprecation.sunset) {
|
|
230
|
+
headers.Sunset = new Date(deprecation.sunset).toUTCString();
|
|
231
|
+
}
|
|
232
|
+
if (deprecation.documentation) {
|
|
233
|
+
headers.Link = `<${deprecation.documentation}>; rel="deprecation"`;
|
|
234
|
+
}
|
|
235
|
+
return headers;
|
|
236
|
+
}
|
package/src/contracts/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
2
|
import type { IdempotencyMeta } from "../idempotency/index.js";
|
|
3
|
+
import type { ContractDeprecationMeta } from "./lifecycle.js";
|
|
3
4
|
import type { OpenAPIOperationMeta } from "./openapi-meta.js";
|
|
4
5
|
import type { RateLimitMeta } from "./rate-limit.js";
|
|
5
6
|
|
|
@@ -228,6 +229,10 @@ export type ContractMeta = {
|
|
|
228
229
|
* OpenAPI operation metadata.
|
|
229
230
|
*/
|
|
230
231
|
openapi?: OpenAPIOperationMeta;
|
|
232
|
+
/**
|
|
233
|
+
* External API lifecycle metadata for a deprecated contract.
|
|
234
|
+
*/
|
|
235
|
+
deprecation?: ContractDeprecationMeta;
|
|
231
236
|
/**
|
|
232
237
|
* Optional rate limit configuration for this contract.
|
|
233
238
|
*
|
package/src/openapi/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type AnyContract,
|
|
9
|
+
type ContractDeprecationMeta,
|
|
9
10
|
type ContractLike,
|
|
10
11
|
getContractHeaderSchemas,
|
|
11
12
|
methodSupportsRequestBody,
|
|
@@ -13,6 +14,10 @@ import {
|
|
|
13
14
|
resolveContract,
|
|
14
15
|
STANDARD_ERROR_RESPONSE_SCHEMA,
|
|
15
16
|
} from "../contracts/index.js";
|
|
17
|
+
import {
|
|
18
|
+
assertValidContractLifecycle,
|
|
19
|
+
getContractOperationId,
|
|
20
|
+
} from "../contracts/lifecycle.js";
|
|
16
21
|
import {
|
|
17
22
|
comparePathParamsToTemplate,
|
|
18
23
|
formatPathParamsMismatch,
|
|
@@ -235,6 +240,8 @@ export interface OperationObject {
|
|
|
235
240
|
* Whether the operation is deprecated.
|
|
236
241
|
*/
|
|
237
242
|
deprecated?: boolean;
|
|
243
|
+
/** Beignet lifecycle details for a deprecated operation. */
|
|
244
|
+
"x-beignet-deprecation"?: ContractDeprecationMeta;
|
|
238
245
|
/**
|
|
239
246
|
* External documentation.
|
|
240
247
|
*/
|
|
@@ -409,9 +416,20 @@ export function contractsToOpenAPI(
|
|
|
409
416
|
createZodSchemaConverter(),
|
|
410
417
|
],
|
|
411
418
|
};
|
|
419
|
+
const operationIds = new Map<string, string>();
|
|
412
420
|
|
|
413
421
|
for (const contract of contracts) {
|
|
414
422
|
const config = resolveContract(contract);
|
|
423
|
+
assertValidContractLifecycle(config);
|
|
424
|
+
const operationId = getContractOperationId(config);
|
|
425
|
+
const route = `${config.method} ${config.path}`;
|
|
426
|
+
const conflictingRoute = operationIds.get(operationId);
|
|
427
|
+
if (conflictingRoute) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`Duplicate OpenAPI operationId: "${operationId}" is used by both ${conflictingRoute} and ${route}. Operation IDs must be unique within an OpenAPI document.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
operationIds.set(operationId, route);
|
|
415
433
|
addContractToPaths(config, paths, state);
|
|
416
434
|
}
|
|
417
435
|
|
|
@@ -454,13 +472,15 @@ function addContractToPaths(
|
|
|
454
472
|
const pathItem = paths[pathKey];
|
|
455
473
|
|
|
456
474
|
const meta = contract.metadata?.openapi;
|
|
475
|
+
const deprecation = contract.metadata?.deprecation;
|
|
457
476
|
|
|
458
477
|
const operation: OperationObject = {
|
|
459
|
-
operationId:
|
|
478
|
+
operationId: getContractOperationId(contract),
|
|
460
479
|
summary: meta?.summary,
|
|
461
480
|
description: meta?.description,
|
|
462
481
|
tags: meta?.tags,
|
|
463
|
-
deprecated: meta?.deprecated,
|
|
482
|
+
deprecated: deprecation ? true : meta?.deprecated,
|
|
483
|
+
"x-beignet-deprecation": deprecation,
|
|
464
484
|
externalDocs: meta?.externalDocs,
|
|
465
485
|
security: meta?.security,
|
|
466
486
|
parameters: [],
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
responseForHooks,
|
|
73
73
|
responseOwnerFor,
|
|
74
74
|
toContractViolationResponse,
|
|
75
|
+
withContractLifecycleHeaders,
|
|
75
76
|
withFrameworkErrorOwnerHeader,
|
|
76
77
|
} from "./response-finalization.js";
|
|
77
78
|
import {
|
|
@@ -947,6 +948,7 @@ export function createRequestExecutor<
|
|
|
947
948
|
finalOwner,
|
|
948
949
|
);
|
|
949
950
|
}
|
|
951
|
+
finalResponse = withContractLifecycleHeaders(finalResponse, contract);
|
|
950
952
|
finalResponse = withoutHeadResponseBody(finalResponse, req.method);
|
|
951
953
|
stages.sendMs = performance.now() - sendStartedAt;
|
|
952
954
|
|
|
@@ -987,7 +989,10 @@ export function createRequestExecutor<
|
|
|
987
989
|
},
|
|
988
990
|
);
|
|
989
991
|
const response = withoutHeadResponseBody(
|
|
990
|
-
|
|
992
|
+
withContractLifecycleHeaders(
|
|
993
|
+
normalizeHttpResponse(result.response),
|
|
994
|
+
contract,
|
|
995
|
+
),
|
|
991
996
|
req.method,
|
|
992
997
|
);
|
|
993
998
|
if (isWebResponse(response)) {
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type ContractErrorDefinition,
|
|
4
4
|
type HttpContractConfig,
|
|
5
5
|
} from "../contracts/index.js";
|
|
6
|
+
import { contractLifecycleResponseHeaders } from "../contracts/lifecycle.js";
|
|
6
7
|
import {
|
|
7
8
|
createErrorResponseBody,
|
|
8
9
|
isErrorResponseBody,
|
|
@@ -74,6 +75,63 @@ export function withFrameworkErrorOwnerHeader(
|
|
|
74
75
|
};
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function setRecordHeader(
|
|
79
|
+
headers: Record<string, string>,
|
|
80
|
+
name: string,
|
|
81
|
+
value: string,
|
|
82
|
+
): void {
|
|
83
|
+
const existingName = Object.keys(headers).find(
|
|
84
|
+
(key) => key.toLowerCase() === name.toLowerCase(),
|
|
85
|
+
);
|
|
86
|
+
if (existingName && existingName !== name) {
|
|
87
|
+
delete headers[existingName];
|
|
88
|
+
}
|
|
89
|
+
headers[name] = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Apply contract-owned deprecation headers to any response representation. */
|
|
93
|
+
export function withContractLifecycleHeaders(
|
|
94
|
+
res: HttpResponse,
|
|
95
|
+
contract: HttpContractConfig,
|
|
96
|
+
): HttpResponse {
|
|
97
|
+
const lifecycleHeaders = contractLifecycleResponseHeaders(contract);
|
|
98
|
+
if (Object.keys(lifecycleHeaders).length === 0) return res;
|
|
99
|
+
|
|
100
|
+
if (isWebResponse(res)) {
|
|
101
|
+
const headers = new Headers(res.headers);
|
|
102
|
+
for (const [name, value] of Object.entries(lifecycleHeaders)) {
|
|
103
|
+
if (name.toLowerCase() === "link" && headers.has(name)) {
|
|
104
|
+
headers.append(name, value);
|
|
105
|
+
} else {
|
|
106
|
+
headers.set(name, value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new Response(res.body, {
|
|
110
|
+
status: res.status,
|
|
111
|
+
statusText: res.statusText,
|
|
112
|
+
headers,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const headers = { ...(res.headers ?? {}) };
|
|
117
|
+
for (const [name, value] of Object.entries(lifecycleHeaders)) {
|
|
118
|
+
if (name.toLowerCase() === "link") {
|
|
119
|
+
const existingName = Object.keys(headers).find(
|
|
120
|
+
(key) => key.toLowerCase() === "link",
|
|
121
|
+
);
|
|
122
|
+
const existing = existingName ? headers[existingName] : undefined;
|
|
123
|
+
setRecordHeader(
|
|
124
|
+
headers,
|
|
125
|
+
name,
|
|
126
|
+
existing ? `${existing}, ${value}` : value,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
setRecordHeader(headers, name, value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { ...res, headers };
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
export function responseOwnerFor(
|
|
78
136
|
res: HttpResponse,
|
|
79
137
|
owner?: ResponseOwner,
|
package/src/server/server.ts
CHANGED
|
@@ -3,6 +3,10 @@ import {
|
|
|
3
3
|
type HttpContractConfig,
|
|
4
4
|
methodSupportsRequestBody,
|
|
5
5
|
} from "../contracts/index.js";
|
|
6
|
+
import {
|
|
7
|
+
assertValidContractLifecycle,
|
|
8
|
+
getContractOperationId,
|
|
9
|
+
} from "../contracts/lifecycle.js";
|
|
6
10
|
import {
|
|
7
11
|
comparePathParamsToTemplate,
|
|
8
12
|
formatPathParamsMismatch,
|
|
@@ -539,6 +543,7 @@ export async function createServer<
|
|
|
539
543
|
const registeredPaths = new Set<string>();
|
|
540
544
|
const registeredShapes = new Map<string, string>();
|
|
541
545
|
const registeredNames = new Map<string, string>();
|
|
546
|
+
const registeredOperationIds = new Map<string, string>();
|
|
542
547
|
|
|
543
548
|
const registerRoute = <C extends HttpContractConfig>(
|
|
544
549
|
contract: C,
|
|
@@ -546,6 +551,7 @@ export async function createServer<
|
|
|
546
551
|
routeHooks: readonly RouteHook<unknown, object>[] = [],
|
|
547
552
|
responseValidationExemptStatus?: number,
|
|
548
553
|
): void => {
|
|
554
|
+
assertValidContractLifecycle(contract);
|
|
549
555
|
if (contract.body && !methodSupportsRequestBody(contract.method)) {
|
|
550
556
|
throw new Error(
|
|
551
557
|
`Request bodies are not supported for ${contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
|
|
@@ -572,6 +578,13 @@ export async function createServer<
|
|
|
572
578
|
`Duplicate contract name: "${contract.name}" is registered for both ${conflictingName} and ${routeKey}. Contract names must be unique because typed clients, OpenAPI operations, and devtools key on them.`,
|
|
573
579
|
);
|
|
574
580
|
}
|
|
581
|
+
const operationId = getContractOperationId(contract);
|
|
582
|
+
const conflictingOperationId = registeredOperationIds.get(operationId);
|
|
583
|
+
if (conflictingOperationId) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`Duplicate OpenAPI operationId: "${operationId}" is registered for both ${conflictingOperationId} and ${routeKey}. Operation IDs must be unique across the registered route surface.`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
575
588
|
if (contract.pathParams) {
|
|
576
589
|
const shape = getObjectSchemaShape(contract.pathParams);
|
|
577
590
|
if (shape) {
|
|
@@ -590,6 +603,7 @@ export async function createServer<
|
|
|
590
603
|
registeredPaths.add(routeKey);
|
|
591
604
|
registeredShapes.set(shapeRouteKey, routeKey);
|
|
592
605
|
registeredNames.set(contract.name, routeKey);
|
|
606
|
+
registeredOperationIds.set(operationId, routeKey);
|
|
593
607
|
|
|
594
608
|
const builtHandler = buildHandler(
|
|
595
609
|
options,
|
package/dist/domain/events.d.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { EventDef, InferEventPayload, StandardSchema } from "../events/index.js";
|
|
2
|
-
/**
|
|
3
|
-
* Domain event definition with a stable name and payload schema.
|
|
4
|
-
*/
|
|
5
|
-
export type DomainEventDef<Name extends string = string, Payload extends StandardSchema = StandardSchema> = EventDef<Name, Payload>;
|
|
6
|
-
/**
|
|
7
|
-
* Infer the payload type from a DomainEventDef.
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* ```ts
|
|
11
|
-
* const UserRegistered = defineDomainEvent(
|
|
12
|
-
* "user.registered",
|
|
13
|
-
* z.object({
|
|
14
|
-
* userId: z.string(),
|
|
15
|
-
* email: z.string().email(),
|
|
16
|
-
* })
|
|
17
|
-
* );
|
|
18
|
-
*
|
|
19
|
-
* type Payload = InferEventPayload<typeof UserRegistered>;
|
|
20
|
-
* // { userId: string; email: string }
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
|
-
export type { InferEventPayload };
|
|
24
|
-
/**
|
|
25
|
-
* Create a new domain event definition.
|
|
26
|
-
*
|
|
27
|
-
* This is a domain-focused alias around `defineEvent(...)` for applications
|
|
28
|
-
* that separate domain events from integration events.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```ts
|
|
32
|
-
* const UserRegistered = defineDomainEvent(
|
|
33
|
-
* "user.registered",
|
|
34
|
-
* z.object({
|
|
35
|
-
* userId: z.string(),
|
|
36
|
-
* email: z.string().email(),
|
|
37
|
-
* })
|
|
38
|
-
* );
|
|
39
|
-
*
|
|
40
|
-
* type UserRegistered = typeof UserRegistered;
|
|
41
|
-
* ```
|
|
42
|
-
*/
|
|
43
|
-
export declare function defineDomainEvent<Name extends string, Payload extends StandardSchema>(name: Name, payload: Payload): DomainEventDef<Name, Payload>;
|
|
44
|
-
//# sourceMappingURL=events.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/domain/events.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,iBAAiB,EACjB,cAAc,EACf,MAAM,oBAAoB,CAAC;AAG5B;;GAEG;AACH,MAAM,MAAM,cAAc,CACxB,IAAI,SAAS,MAAM,GAAG,MAAM,EAC5B,OAAO,SAAS,cAAc,GAAG,cAAc,IAC7C,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AAE5B;;;;;;;;;;;;;;;;GAgBG;AACH,YAAY,EAAE,iBAAiB,EAAE,CAAC;AAElC;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,SAAS,MAAM,EACnB,OAAO,SAAS,cAAc,EAC9B,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,GAAG,cAAc,CAAC,IAAI,EAAE,OAAO,CAAC,CAE7D"}
|
package/dist/domain/events.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { defineEvent } from "../events/index.js";
|
|
2
|
-
/**
|
|
3
|
-
* Create a new domain event definition.
|
|
4
|
-
*
|
|
5
|
-
* This is a domain-focused alias around `defineEvent(...)` for applications
|
|
6
|
-
* that separate domain events from integration events.
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```ts
|
|
10
|
-
* const UserRegistered = defineDomainEvent(
|
|
11
|
-
* "user.registered",
|
|
12
|
-
* z.object({
|
|
13
|
-
* userId: z.string(),
|
|
14
|
-
* email: z.string().email(),
|
|
15
|
-
* })
|
|
16
|
-
* );
|
|
17
|
-
*
|
|
18
|
-
* type UserRegistered = typeof UserRegistered;
|
|
19
|
-
* ```
|
|
20
|
-
*/
|
|
21
|
-
export function defineDomainEvent(name, payload) {
|
|
22
|
-
return defineEvent(name, { payload });
|
|
23
|
-
}
|
|
24
|
-
//# sourceMappingURL=events.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"events.js","sourceRoot":"","sources":["../../src/domain/events.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AA6BjD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,iBAAiB,CAG/B,IAAU,EAAE,OAAgB;IAC5B,OAAO,WAAW,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;AACxC,CAAC"}
|