@sezzlee/openapi 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaan Akın
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # @sezzlee/openapi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Turns a Swagger 2.0 or OpenAPI 3.0–3.2 document into the same `EndpointDescriptor[]` shape sezzlee's
4
+ catalog builds from framework discovery. This is the ingestion library; everything after the
5
+ descriptor — naming, selection, curation, template production, composition, error mapping, search
6
+ — is the existing catalog and lives elsewhere. The one consumer today is
7
+ [`@sezzlee/openapi-mcp`](../../servers/openapi-mcp).
8
+
9
+ > Status: `0.2.0`, alpha. Normative source: [openapi-ingestion.md](../spec/openapi-ingestion.md).
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { ingest } from "@sezzlee/openapi";
15
+
16
+ const result = await ingest(documentTextOrObject, {
17
+ documentUrl: "file:///abs/path/to/openapi.json",
18
+ baseUrl: "https://api.example.com",
19
+ strict: false,
20
+ });
21
+
22
+ if (result.fatal) {
23
+ // result.diagnostics holds at least one "fatal" entry
24
+ }
25
+ ```
26
+
27
+ ### `ingest(source, options?)`
28
+
29
+ `source` is either the document's raw text (JSON or YAML) or an already-parsed `JsonObject`.
30
+ `options` (`IngestOptions`, from `src/ingest.ts`):
31
+
32
+ | Option | Type | Default | Notes |
33
+ | --------------------- | ---------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------- |
34
+ | `documentUrl` | `string` | — | Where the document was read from; relative servers and external `$ref`s resolve against it |
35
+ | `baseUrl` | `string` | — | Replaces the document's root `servers` |
36
+ | `serverVariables` | `Readonly<Record<string, string>>` | — | Overrides for server variable defaults |
37
+ | `loader` | `DocumentLoader` | — | Reads an external `$ref`; without one, a document that has any is refused (`external_ref_blocked`, fatal) |
38
+ | `strict` | `boolean` | `false` | Raises `openapi_document_invalid` from a warning to a fatal diagnostic |
39
+ | `cookieDenyList` | `RegExp` | the built-in deny-list | Cookie names treated as identity carriers even when no security scheme declares them |
40
+ | `identityCookies` | `readonly string[]` | `[]` | Exact cookie names added to the deny-list (case-insensitive); they extend it and never replace it |
41
+ | `outputSchema` | `"document"` \| `"omit"` | `"document"` | `omit` for a backend whose responses do not match its document |
42
+ | `hoistPathPrefix` | `string` | — | A leading path segment moved from every route into the base URL |
43
+ | `requestBodyRequired` | `"document"` \| `"always"` | `"document"` | `always` treats an undeclared `requestBody.required` as `true` |
44
+
45
+ `ingest` returns an `IngestionResult`:
46
+
47
+ | Field | Type | Meaning |
48
+ | ------------- | -------------------------------- | ------------------------------------------------------------------ |
49
+ | `endpoints` | `readonly SourcedEndpoint[]` | Empty when `fatal` is `true` |
50
+ | `rootBaseUrl` | `string \| undefined` | The resolved root server, or the `baseUrl` option that replaced it |
51
+ | `security` | `SecurityModel` | The document's security schemes, keyed by scheme name |
52
+ | `diagnostics` | `readonly IngestionDiagnostic[]` | Every diagnostic produced, fatal or not |
53
+ | `fatal` | `boolean` | `true` when at least one diagnostic is `"fatal"` |
54
+
55
+ ## No I/O
56
+
57
+ `ingest` performs no network or filesystem access itself: `node:http`, `node:https`, `node:net`,
58
+ `node:tls`, `node:fs`, `node:fs/promises`, `undici`, `node-fetch`, `axios`, and the global `fetch`,
59
+ `WebSocket` and `EventSource` are all lint-banned across `src/` (`oxlint.config.ts`). Reading the
60
+ document itself is the caller's job; reading an **external** `$ref` (another file or a URL the
61
+ document points to) is done only through the `loader` a caller injects:
62
+
63
+ ```ts
64
+ export type DocumentLoader = (url: URL) => Promise<string>;
65
+ ```
66
+
67
+ Without a `loader` (and a `documentUrl` to resolve relative references against), a document that
68
+ contains any external `$ref` is refused with `external_ref_blocked` rather than read — ingestion
69
+ never decides on its own which file or host it may reach; that decision belongs to the host. A
70
+ loader is expected to enforce its own limits (an allowlisted host, a deadline, a size cap, a root
71
+ directory for a file); `@sezzlee/openapi-mcp`'s loader is one example, restricted to the document's
72
+ own host plus its configured `refHosts`, with a 64 MiB cap.
73
+
74
+ ## Diagnostics
75
+
76
+ Every construct the document contains that ingestion cannot represent produces a diagnostic
77
+ instead of throwing — only the `loader`'s own I/O can reject. A diagnostic (`IngestionDiagnostic`,
78
+ `src/diagnostics.ts`) carries:
79
+
80
+ - `code` — one of the `IngestionCode` values (`openapi_document_unparseable`,
81
+ `external_ref_blocked`, `unsupported_media_type`, `identity_cookie_parameter`, …; the full list
82
+ and each code's severity is in [openapi-ingestion.md](../spec/openapi-ingestion.md#diagnostics))
83
+ - `severity` — one of three: `"fatal"` (stops the catalog), `"endpointDropped"` (removes one
84
+ operation), `"warning"` (changes nothing the agent can reach)
85
+ - `at` — an RFC 6901 `JsonPointer` into the **original** document
86
+ - `message` — human-readable, addressed to the document's author
87
+
88
+ `ingestionSeverities` (exported from the package) is `as const satisfies Readonly<Record<string,
89
+ CatalogSeverity>>`, so a code added without a severity fails to compile.
90
+
91
+ ## Rules
92
+
93
+ - No runtime dependency on any other `@sezzlee/*` package besides `@sezzlee/core`, which supplies
94
+ `CatalogSeverity` and the descriptor types this library fills in.
95
+ - Bound by the `openapi-ingestion` fixture profile, not the core catalog's fixture profile — an
96
+ SDK that never reads an OpenAPI document is unaffected by a change here.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ pnpm turbo run build --filter=@sezzlee/openapi
102
+ pnpm turbo run lint --filter=@sezzlee/openapi
103
+ pnpm turbo run check-types --filter=@sezzlee/openapi
104
+ pnpm turbo run test --filter=@sezzlee/openapi
105
+ ```
106
+
107
+ Run these through Turbo rather than `pnpm --filter @sezzlee/openapi <task>`, per this repository's
108
+ convention. `test/ingest.spec.ts` covers the pipeline directly; `test/conformance-fixtures.spec.ts`
109
+ runs the package against the `openapi-ingestion` fixture corpus.
@@ -0,0 +1,61 @@
1
+ import type { CatalogSeverity } from "@sezzlee/core";
2
+ import type { JsonPointer } from "./ir/brand.js";
3
+ export declare const ingestionSeverities: {
4
+ readonly openapi_document_unparseable: "fatal";
5
+ readonly openapi_version_unsupported: "fatal";
6
+ readonly openapi_document_invalid: "warning";
7
+ readonly external_ref_blocked: "fatal";
8
+ readonly server_variable_invalid: "fatal";
9
+ readonly server_url_unresolvable: "fatal";
10
+ readonly circular_component_ref: "endpointDropped";
11
+ readonly recursive_parameter_schema: "endpointDropped";
12
+ readonly unsupported_method: "endpointDropped";
13
+ readonly unsupported_collection_format: "endpointDropped";
14
+ readonly unsupported_parameter_content: "endpointDropped";
15
+ readonly unsupported_media_type: "endpointDropped";
16
+ readonly unsupported_encoding: "endpointDropped";
17
+ readonly streaming_response_unsupported: "endpointDropped";
18
+ readonly server_host_not_allowed: "endpointDropped";
19
+ readonly security_unsatisfiable: "endpointDropped";
20
+ readonly security_scheme_unsupported: "warning";
21
+ readonly ref_siblings_ignored: "warning";
22
+ readonly annotation_removed: "warning";
23
+ readonly reserved_header_parameter_ignored: "warning";
24
+ readonly credential_parameter_ignored: "warning";
25
+ readonly allow_empty_value_ignored: "warning";
26
+ readonly identity_cookie_parameter: "warning";
27
+ readonly identity_cookie_uncovered: "warning";
28
+ readonly request_media_type_alternative_ignored: "warning";
29
+ readonly operation_id_unusable: "warning";
30
+ readonly search_terms_invalid: "warning";
31
+ readonly callbacks_ignored: "warning";
32
+ readonly links_ignored: "warning";
33
+ readonly webhooks_ignored: "warning";
34
+ };
35
+ export type IngestionCode = keyof typeof ingestionSeverities;
36
+ export interface IngestionDiagnostic {
37
+ readonly code: IngestionCode;
38
+ readonly severity: CatalogSeverity;
39
+ readonly at: JsonPointer;
40
+ readonly message: string;
41
+ }
42
+ /**
43
+ * Collects ingestion diagnostics.
44
+ *
45
+ * @param strict raises `openapi_document_invalid` to fatal
46
+ */
47
+ export declare class DiagnosticSink {
48
+ private readonly strict;
49
+ private readonly entries;
50
+ private readonly reported;
51
+ constructor(strict?: boolean);
52
+ report(code: IngestionCode, at: JsonPointer, message: string): void;
53
+ get all(): readonly IngestionDiagnostic[];
54
+ get fatal(): boolean;
55
+ }
56
+ /** Raised inside one operation's lowering; the operation is dropped and the diagnostic reported. */
57
+ export declare class OperationDropped extends Error {
58
+ readonly code: IngestionCode;
59
+ readonly at: JsonPointer;
60
+ constructor(code: IngestionCode, at: JsonPointer, message: string);
61
+ }
@@ -0,0 +1,82 @@
1
+ export const ingestionSeverities = {
2
+ openapi_document_unparseable: "fatal",
3
+ openapi_version_unsupported: "fatal",
4
+ openapi_document_invalid: "warning",
5
+ external_ref_blocked: "fatal",
6
+ server_variable_invalid: "fatal",
7
+ server_url_unresolvable: "fatal",
8
+ circular_component_ref: "endpointDropped",
9
+ recursive_parameter_schema: "endpointDropped",
10
+ unsupported_method: "endpointDropped",
11
+ unsupported_collection_format: "endpointDropped",
12
+ unsupported_parameter_content: "endpointDropped",
13
+ unsupported_media_type: "endpointDropped",
14
+ unsupported_encoding: "endpointDropped",
15
+ streaming_response_unsupported: "endpointDropped",
16
+ server_host_not_allowed: "endpointDropped",
17
+ security_unsatisfiable: "endpointDropped",
18
+ security_scheme_unsupported: "warning",
19
+ ref_siblings_ignored: "warning",
20
+ annotation_removed: "warning",
21
+ reserved_header_parameter_ignored: "warning",
22
+ credential_parameter_ignored: "warning",
23
+ allow_empty_value_ignored: "warning",
24
+ identity_cookie_parameter: "warning",
25
+ identity_cookie_uncovered: "warning",
26
+ request_media_type_alternative_ignored: "warning",
27
+ operation_id_unusable: "warning",
28
+ search_terms_invalid: "warning",
29
+ callbacks_ignored: "warning",
30
+ links_ignored: "warning",
31
+ webhooks_ignored: "warning",
32
+ };
33
+ const oncePerDocument = new Set([
34
+ "annotation_removed",
35
+ "ref_siblings_ignored",
36
+ "callbacks_ignored",
37
+ "links_ignored",
38
+ "webhooks_ignored",
39
+ ]);
40
+ /**
41
+ * Collects ingestion diagnostics.
42
+ *
43
+ * @param strict raises `openapi_document_invalid` to fatal
44
+ */
45
+ export class DiagnosticSink {
46
+ strict;
47
+ entries = [];
48
+ reported = new Set();
49
+ constructor(strict = false) {
50
+ this.strict = strict;
51
+ }
52
+ report(code, at, message) {
53
+ if (oncePerDocument.has(code)) {
54
+ const key = `${code}|${message}`;
55
+ if (this.reported.has(key)) {
56
+ return;
57
+ }
58
+ this.reported.add(key);
59
+ }
60
+ const severity = this.strict && code === "openapi_document_invalid"
61
+ ? "fatal"
62
+ : ingestionSeverities[code];
63
+ this.entries.push({ code, severity, at, message });
64
+ }
65
+ get all() {
66
+ return this.entries;
67
+ }
68
+ get fatal() {
69
+ return this.entries.some((entry) => entry.severity === "fatal");
70
+ }
71
+ }
72
+ /** Raised inside one operation's lowering; the operation is dropped and the diagnostic reported. */
73
+ export class OperationDropped extends Error {
74
+ code;
75
+ at;
76
+ constructor(code, at, message) {
77
+ super(message);
78
+ this.code = code;
79
+ this.at = at;
80
+ this.name = "OperationDropped";
81
+ }
82
+ }
@@ -0,0 +1,9 @@
1
+ export { ingest } from "./ingest.js";
2
+ export type { IngestOptions, IngestionResult } from "./ingest.js";
3
+ export { ingestionSeverities } from "./diagnostics.js";
4
+ export type { IngestionCode, IngestionDiagnostic } from "./diagnostics.js";
5
+ export type { DocumentLoader } from "./normalize/external.js";
6
+ export type { SourcedEndpoint } from "./lower/operations.js";
7
+ export type { CredentialRef, SecurityModel, SecurityRequirement, SecurityScheme, } from "./lower/security.js";
8
+ export type { HttpMethod, JsonPointer, OperationKey } from "./ir/brand.js";
9
+ export type { JsonObject, JsonValue } from "./ir/json.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ingest } from "./ingest.js";
2
+ export { ingestionSeverities } from "./diagnostics.js";
@@ -0,0 +1,43 @@
1
+ import { type IngestionDiagnostic } from "./diagnostics.js";
2
+ import { type JsonObject } from "./ir/json.js";
3
+ import { type DocumentLoader } from "./normalize/external.js";
4
+ import { type SourcedEndpoint } from "./lower/operations.js";
5
+ import { type SecurityModel } from "./lower/security.js";
6
+ /**
7
+ * @param documentUrl where the document was read from; relative servers and external references
8
+ * resolve against it
9
+ * @param baseUrl replaces the document's root servers
10
+ * @param loader reads external references; without one, a document that has any is refused
11
+ * @param strict makes a document that fails validation fatal
12
+ * @param cookieDenyList cookie names treated as identity even when no security scheme declares them
13
+ * @param identityCookies further cookie names treated as identity, on top of the deny-list
14
+ * @param outputSchema `omit` for a backend whose responses do not match its document
15
+ * @param hoistPathPrefix a leading path segment moved from every route into the base URL
16
+ * @param requestBodyRequired `always` treats an undeclared `requestBody.required` as true, for a
17
+ * generator that omits it although the backend rejects an empty body
18
+ */
19
+ export interface IngestOptions {
20
+ readonly documentUrl?: string;
21
+ readonly baseUrl?: string;
22
+ readonly serverVariables?: Readonly<Record<string, string>>;
23
+ readonly loader?: DocumentLoader;
24
+ readonly strict?: boolean;
25
+ readonly cookieDenyList?: RegExp;
26
+ readonly identityCookies?: readonly string[];
27
+ readonly outputSchema?: "document" | "omit";
28
+ readonly hoistPathPrefix?: string;
29
+ readonly requestBodyRequired?: "document" | "always";
30
+ }
31
+ export interface IngestionResult {
32
+ readonly endpoints: readonly SourcedEndpoint[];
33
+ /** The resolved root server, or the configured base URL that replaces it. */
34
+ readonly rootBaseUrl?: string;
35
+ readonly security: SecurityModel;
36
+ readonly diagnostics: readonly IngestionDiagnostic[];
37
+ readonly fatal: boolean;
38
+ }
39
+ /**
40
+ * Turns an OpenAPI document into catalog descriptors. It never throws for anything the document
41
+ * contains: every construct it cannot carry is a diagnostic. Only the loader's I/O can reject.
42
+ */
43
+ export declare function ingest(source: string | JsonObject, options?: IngestOptions): Promise<IngestionResult>;
package/dist/ingest.js ADDED
@@ -0,0 +1,80 @@
1
+ import { DiagnosticSink } from "./diagnostics.js";
2
+ import { isObject } from "./ir/json.js";
3
+ import { bundleExternal } from "./normalize/external.js";
4
+ import { lowerOperations } from "./lower/operations.js";
5
+ import { defaultCookieDenyList } from "./lower/parameters.js";
6
+ import { securitySchemesOf } from "./lower/security.js";
7
+ import { serverOf } from "./lower/servers.js";
8
+ import { childPointer, rootPointer } from "./ir/brand.js";
9
+ import { parseDocument, versionOf } from "./parse/parse.js";
10
+ import { upgradeSwagger2 } from "./parse/swagger2.js";
11
+ /**
12
+ * Turns an OpenAPI document into catalog descriptors. It never throws for anything the document
13
+ * contains: every construct it cannot carry is a diagnostic. Only the loader's I/O can reject.
14
+ */
15
+ export async function ingest(source, options = {}) {
16
+ const diagnostics = new DiagnosticSink(options.strict ?? false);
17
+ const failed = () => ({
18
+ endpoints: [],
19
+ security: new Map(),
20
+ diagnostics: diagnostics.all,
21
+ fatal: true,
22
+ });
23
+ const parsed = typeof source === "string" ? parseDocument(source, diagnostics) : source;
24
+ if (parsed === undefined || !isObject(parsed)) {
25
+ return failed();
26
+ }
27
+ const version = versionOf(parsed, diagnostics);
28
+ if (version === undefined) {
29
+ return failed();
30
+ }
31
+ const upgraded = version === "2.0"
32
+ ? upgradeSwagger2(parsed, diagnostics, new WeakMap())
33
+ : parsed;
34
+ const document = await bundleExternal(upgraded, options.documentUrl, options.loader, diagnostics);
35
+ if (document === undefined || diagnostics.fatal) {
36
+ return failed();
37
+ }
38
+ const context = {
39
+ document,
40
+ version: version === "2.0" ? "3.0" : version,
41
+ diagnostics,
42
+ };
43
+ const security = securitySchemesOf(document, diagnostics);
44
+ const operationOptions = {
45
+ ...(options.documentUrl === undefined
46
+ ? {}
47
+ : { documentUrl: options.documentUrl }),
48
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
49
+ ...(options.serverVariables === undefined
50
+ ? {}
51
+ : { variables: options.serverVariables }),
52
+ cookieDenyList: withIdentityCookies(options.cookieDenyList ?? defaultCookieDenyList, options.identityCookies ?? []),
53
+ outputSchema: options.outputSchema ?? "document",
54
+ requestBodyRequired: options.requestBodyRequired ?? "document",
55
+ ...(options.hoistPathPrefix === undefined
56
+ ? {}
57
+ : { hoistPathPrefix: options.hoistPathPrefix }),
58
+ };
59
+ const endpoints = lowerOperations(context, security, operationOptions);
60
+ const rootBaseUrl = serverOf([], { servers: document["servers"], at: childPointer(rootPointer, "servers") }, operationOptions, new DiagnosticSink());
61
+ return {
62
+ endpoints: diagnostics.fatal ? [] : endpoints,
63
+ ...(rootBaseUrl === undefined ? {} : { rootBaseUrl }),
64
+ security,
65
+ diagnostics: diagnostics.all,
66
+ fatal: diagnostics.fatal,
67
+ };
68
+ }
69
+ /**
70
+ * Guard: the names extend the deny-list and never replace it. An operator adding
71
+ * the one session cookie their backend uses must not thereby switch off the
72
+ * default names that catch every other one.
73
+ */
74
+ function withIdentityCookies(denyList, names) {
75
+ if (names.length === 0) {
76
+ return denyList;
77
+ }
78
+ const exact = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
79
+ return new RegExp(`${denyList.source}|^(?:${exact.join("|")})$`, "i");
80
+ }
@@ -0,0 +1,13 @@
1
+ declare const brand: unique symbol;
2
+ export type Brand<T, B extends string> = T & {
3
+ readonly [brand]: B;
4
+ };
5
+ /** An RFC 6901 pointer into the document as the author wrote it. */
6
+ export type JsonPointer = Brand<string, "JsonPointer">;
7
+ export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "QUERY" | "TRACE";
8
+ export type OperationKey = Brand<`${HttpMethod} ${string}`, "OperationKey">;
9
+ export declare const rootPointer: JsonPointer;
10
+ export declare function childPointer(parent: JsonPointer, ...segments: readonly (string | number)[]): JsonPointer;
11
+ export declare function segmentsOf(pointer: string): string[];
12
+ export declare function operationKey(method: HttpMethod, path: string): OperationKey;
13
+ export {};
@@ -0,0 +1,18 @@
1
+ export const rootPointer = "";
2
+ const escapeSegment = (segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
3
+ export function childPointer(parent, ...segments) {
4
+ return `${parent}${segments.map((segment) => `/${escapeSegment(segment)}`).join("")}`;
5
+ }
6
+ export function segmentsOf(pointer) {
7
+ if (pointer === "" || pointer === "#") {
8
+ return [];
9
+ }
10
+ const body = pointer.startsWith("#") ? pointer.slice(1) : pointer;
11
+ return body
12
+ .split("/")
13
+ .slice(1)
14
+ .map((segment) => decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~"));
15
+ }
16
+ export function operationKey(method, path) {
17
+ return `${method} ${path}`;
18
+ }
@@ -0,0 +1,14 @@
1
+ export type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject;
2
+ export interface JsonObject {
3
+ readonly [key: string]: JsonValue | undefined;
4
+ }
5
+ export type MutableJsonObject = {
6
+ [key: string]: JsonValue | undefined;
7
+ };
8
+ export declare const isObject: (value: unknown) => value is JsonObject;
9
+ export declare const objectOf: (value: unknown) => JsonObject | undefined;
10
+ export declare const stringOf: (value: unknown) => string | undefined;
11
+ export declare const booleanOf: (value: unknown) => boolean | undefined;
12
+ export declare const arrayOf: (value: unknown) => readonly JsonValue[];
13
+ export declare function entriesOf(value: unknown): ReadonlyArray<readonly [string, JsonValue]>;
14
+ export declare function clone<T extends JsonValue>(value: T): T;
@@ -0,0 +1,14 @@
1
+ export const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2
+ export const objectOf = (value) => isObject(value) ? value : undefined;
3
+ export const stringOf = (value) => typeof value === "string" ? value : undefined;
4
+ export const booleanOf = (value) => typeof value === "boolean" ? value : undefined;
5
+ export const arrayOf = (value) => Array.isArray(value) ? value : [];
6
+ export function entriesOf(value) {
7
+ if (!isObject(value)) {
8
+ return [];
9
+ }
10
+ return Object.entries(value).filter((entry) => entry[1] !== undefined);
11
+ }
12
+ export function clone(value) {
13
+ return structuredClone(value);
14
+ }
@@ -0,0 +1,6 @@
1
+ import { type EndpointDescriptor } from "@sezzlee/core";
2
+ import { type JsonPointer } from "../ir/brand.js";
3
+ import { type SchemaContext } from "../normalize/schema.js";
4
+ type RequestBody = NonNullable<EndpointDescriptor["requestBody"]>;
5
+ export declare function lowerRequestBody(context: SchemaContext, value: unknown, at: JsonPointer, requiredDefault?: "document" | "always"): RequestBody | undefined;
6
+ export {};
@@ -0,0 +1,127 @@
1
+ import { isBinaryMediaType, isJsonMediaType, jsonMediaType, multipartMediaType, textMediaType, urlEncodedMediaType, } from "@sezzlee/core";
2
+ import { OperationDropped } from "../diagnostics.js";
3
+ import { childPointer } from "../ir/brand.js";
4
+ import { entriesOf, isObject, objectOf, stringOf, } from "../ir/json.js";
5
+ import { resolveComponent } from "../normalize/refs.js";
6
+ import { normalizeSlot } from "../normalize/schema.js";
7
+ const bareMediaType = (declared) => (declared.split(";")[0] ?? "").trim().toLowerCase();
8
+ const isFileRoot = (schema) => schema.contentMediaType !== undefined && schema.contentEncoding === undefined;
9
+ const hasFileField = (schema) => Object.values(schema.properties ?? {}).some((property) => (property.contentMediaType !== undefined &&
10
+ property.contentEncoding === undefined) ||
11
+ (property.items?.contentMediaType !== undefined &&
12
+ property.items.contentEncoding === undefined));
13
+ /**
14
+ * Picks the one media type the body is written as, in the order of request-bodies.md: JSON (also
15
+ * when a wildcard covers it), another JSON-family type in declared order, urlencoded when no field
16
+ * is a file, multipart, text/plain.
17
+ */
18
+ function choose(candidates, schemaOf) {
19
+ const exact = candidates.find((candidate) => candidate.mediaType === jsonMediaType);
20
+ if (exact !== undefined) {
21
+ return exact;
22
+ }
23
+ const wildcard = candidates.find((candidate) => candidate.mediaType === "*/*" || candidate.mediaType === "application/*");
24
+ if (wildcard !== undefined) {
25
+ return { ...wildcard, mediaType: jsonMediaType };
26
+ }
27
+ const family = candidates.find((candidate) => isJsonMediaType(candidate.mediaType));
28
+ if (family !== undefined) {
29
+ return family;
30
+ }
31
+ const urlencoded = candidates.find((candidate) => candidate.mediaType === urlEncodedMediaType);
32
+ if (urlencoded !== undefined && !hasFileField(schemaOf(urlencoded))) {
33
+ return urlencoded;
34
+ }
35
+ return (candidates.find((candidate) => candidate.mediaType === multipartMediaType) ??
36
+ candidates.find((candidate) => candidate.mediaType === textMediaType) ??
37
+ candidates.find((candidate) => candidate.mediaType === "application/octet-stream" &&
38
+ isFileRoot(schemaOf(candidate))) ??
39
+ candidates.find((candidate) => isBinaryMediaType(candidate.mediaType) &&
40
+ !candidate.mediaType.includes("*") &&
41
+ isFileRoot(schemaOf(candidate))));
42
+ }
43
+ function assertEncoding(media, at) {
44
+ for (const [field, raw] of entriesOf(media["encoding"])) {
45
+ const encoding = objectOf(raw);
46
+ const style = stringOf(encoding?.["style"]);
47
+ const unsupported = (style !== undefined && style !== "form") ||
48
+ encoding?.["explode"] === false ||
49
+ encoding?.["allowReserved"] === true ||
50
+ objectOf(encoding?.["headers"]) !== undefined;
51
+ if (unsupported) {
52
+ throw new OperationDropped("unsupported_encoding", childPointer(at, "encoding", field), `The encoding of '${field}' declares a style, explode, allowReserved or part headers the form writers do not produce.`);
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * A body whose root admits `null` is a body the backend accepts absent: `null` is not a JSON body
58
+ * a client sends, it is how a generator writes an optional parameter's type (`NotePayload?`). The
59
+ * null member is removed so the root can flatten, and the body is optional unless the document
60
+ * declares it required.
61
+ */
62
+ function withoutNullRoot(schema) {
63
+ const type = schema.type;
64
+ if (Array.isArray(type) && type.includes("null")) {
65
+ const rest = type.filter((member) => member !== "null");
66
+ return {
67
+ schema: {
68
+ ...schema,
69
+ type: rest.length === 1 ? rest[0] : rest,
70
+ },
71
+ nullable: true,
72
+ };
73
+ }
74
+ return { schema, nullable: false };
75
+ }
76
+ export function lowerRequestBody(context, value, at, requiredDefault = "document") {
77
+ if (value === undefined) {
78
+ return undefined;
79
+ }
80
+ const resolved = resolveComponent(context.document, isObject(value) ? value : {}, at);
81
+ if (resolved === undefined) {
82
+ return undefined;
83
+ }
84
+ const { node, at: bodyAt } = resolved;
85
+ const contentAt = childPointer(bodyAt, "content");
86
+ const candidates = entriesOf(node["content"]).flatMap(([declared, media]) => isObject(media)
87
+ ? [{ declared, mediaType: bareMediaType(declared), media }]
88
+ : []);
89
+ const schemaOf = (candidate) => {
90
+ const mediaAt = childPointer(contentAt, candidate.declared);
91
+ const encoding = objectOf(candidate.media["encoding"]);
92
+ return normalizeSlot(context, candidate.media["schema"], childPointer(mediaAt, "schema"), {
93
+ direction: "request",
94
+ root: "preferred",
95
+ fileMediaType: (property) => stringOf(objectOf(encoding?.[property])?.["contentType"])
96
+ ?.split(",")[0]
97
+ ?.trim(),
98
+ });
99
+ };
100
+ const chosen = choose(candidates, schemaOf);
101
+ if (chosen === undefined) {
102
+ throw new OperationDropped("unsupported_media_type", contentAt, `No declared body media type has a writer: ${candidates.map((candidate) => candidate.declared).join(", ") || "none declared"}.`);
103
+ }
104
+ for (const candidate of candidates) {
105
+ if (candidate.declared !== chosen.declared) {
106
+ context.diagnostics.report("request_media_type_alternative_ignored", childPointer(contentAt, candidate.declared), `The body is written as ${chosen.mediaType}; the alternative ${candidate.declared} is not used.`);
107
+ }
108
+ }
109
+ assertEncoding(chosen.media, childPointer(contentAt, chosen.declared));
110
+ const description = stringOf(node["description"]);
111
+ const { schema: normalized, nullable } = withoutNullRoot(schemaOf(chosen));
112
+ const schema = isBinaryMediaType(chosen.mediaType) && isFileRoot(normalized)
113
+ ? { ...normalized, contentMediaType: chosen.mediaType }
114
+ : normalized;
115
+ const required = node["required"] === true ||
116
+ (node["required"] === undefined &&
117
+ requiredDefault === "always" &&
118
+ !nullable);
119
+ return {
120
+ schema,
121
+ ...(required ? {} : { required: false }),
122
+ ...(description === undefined ? {} : { description }),
123
+ ...(chosen.mediaType === jsonMediaType
124
+ ? {}
125
+ : { contentType: chosen.mediaType }),
126
+ };
127
+ }
@@ -0,0 +1,20 @@
1
+ import { type EndpointDescriptor } from "@sezzlee/core";
2
+ import { type JsonPointer, type OperationKey } from "../ir/brand.js";
3
+ import type { SchemaContext } from "../normalize/schema.js";
4
+ import { type SecurityModel, type SecurityRequirement } from "./security.js";
5
+ import { type ServerOptions } from "./servers.js";
6
+ export interface SourcedEndpoint {
7
+ readonly key: OperationKey;
8
+ readonly at: JsonPointer;
9
+ readonly descriptor: EndpointDescriptor;
10
+ /** The base URL the route is appended to, with any hoisted prefix; `undefined` when unresolvable. */
11
+ readonly baseUrl: string | undefined;
12
+ readonly security: readonly SecurityRequirement[] | undefined;
13
+ }
14
+ export interface OperationOptions extends ServerOptions {
15
+ readonly cookieDenyList: RegExp;
16
+ readonly outputSchema: "document" | "omit";
17
+ readonly requestBodyRequired: "document" | "always";
18
+ readonly hoistPathPrefix?: string;
19
+ }
20
+ export declare function lowerOperations(context: SchemaContext, schemes: SecurityModel, options: OperationOptions): SourcedEndpoint[];