@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 +21 -0
- package/README.md +108 -2
- package/dist/diagnostics.d.ts +61 -0
- package/dist/diagnostics.js +82 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +2 -0
- package/dist/ingest.d.ts +43 -0
- package/dist/ingest.js +80 -0
- package/dist/ir/brand.d.ts +13 -0
- package/dist/ir/brand.js +18 -0
- package/dist/ir/json.d.ts +14 -0
- package/dist/ir/json.js +14 -0
- package/dist/lower/body.d.ts +6 -0
- package/dist/lower/body.js +127 -0
- package/dist/lower/operations.d.ts +20 -0
- package/dist/lower/operations.js +212 -0
- package/dist/lower/parameters.d.ts +30 -0
- package/dist/lower/parameters.js +143 -0
- package/dist/lower/responses.d.ts +6 -0
- package/dist/lower/responses.js +63 -0
- package/dist/lower/security.d.ts +41 -0
- package/dist/lower/security.js +113 -0
- package/dist/lower/servers.d.ts +22 -0
- package/dist/lower/servers.js +50 -0
- package/dist/normalize/external.d.ts +9 -0
- package/dist/normalize/external.js +93 -0
- package/dist/normalize/refs.d.ts +15 -0
- package/dist/normalize/refs.js +49 -0
- package/dist/normalize/schema.d.ts +24 -0
- package/dist/normalize/schema.js +354 -0
- package/dist/parse/parse.d.ts +5 -0
- package/dist/parse/parse.js +40 -0
- package/dist/parse/swagger2.d.ts +11 -0
- package/dist/parse/swagger2.js +394 -0
- package/package.json +58 -3
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
|
-
#
|
|
1
|
+
# @sezzlee/openapi
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/ingest.d.ts
ADDED
|
@@ -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 {};
|
package/dist/ir/brand.js
ADDED
|
@@ -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;
|
package/dist/ir/json.js
ADDED
|
@@ -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[];
|