Skip to content

Commit ef0fbab

Browse files
committed
feat(openapi3): add fully-dereferenced OpenAPI 3 types and replace custom guards
* Introduce `src/openapi3/dereferencedOpenApiv3.ts`, providing fully-dereferenced versions of core OpenAPI 3 objects (Document, OperationObject, SchemaObject, etc.). * Refactor `handleJson.ts` to consume the new types: * Remove bespoke type-guard helpers (`isParameter`, `isSchema`, …). * Eliminate most explicit `as` casts and manual type-narrowing logic. * Simplify enum/array handling through shared helpers (`buildEnumObject`, `getArrayType`). Signed-off-by: J3m5 <5523410+J3m5@users.noreply.github.com>
1 parent 1e897ee commit ef0fbab

2 files changed

Lines changed: 200 additions & 120 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// oxlint-disable consistent-indexed-object-style
2+
import type { OpenAPIV3 } from "openapi-types";
3+
4+
export type OpenAPIV3Document<T extends object = object> = Omit<
5+
OpenAPIV3.Document,
6+
"paths" | "components"
7+
> & {
8+
paths: PathsObject<T>;
9+
components?: ComponentsObject;
10+
};
11+
12+
interface PathsObject<T extends object = object, P extends object = object> {
13+
[pattern: string]: (PathItemObject<T> & P) | undefined;
14+
}
15+
16+
type PathItemObject<T extends object = object> = Omit<
17+
OpenAPIV3.PathItemObject,
18+
"parameters" | `${OpenAPIV3.HttpMethods}`
19+
> & {
20+
parameters?: ParameterObject[];
21+
} & {
22+
[method in OpenAPIV3.HttpMethods]?: OperationObject<T>;
23+
};
24+
25+
export type OperationObject<T extends object = object> = Omit<
26+
OpenAPIV3.OperationObject,
27+
"parameters" | "requestBody" | "responses" | "callbacks"
28+
> & {
29+
parameters?: ParameterObject[];
30+
requestBody?: RequestBodyObject;
31+
responses: ResponsesObject;
32+
callbacks?: {
33+
[callback: string]: CallbackObject;
34+
};
35+
} & T;
36+
interface ParameterObject extends ParameterBaseObject {
37+
name: string;
38+
in: string;
39+
}
40+
interface HeaderObject extends ParameterBaseObject {}
41+
type ParameterBaseObject = Omit<
42+
OpenAPIV3.ParameterObject,
43+
"schema" | "content"
44+
> & {
45+
schema?: SchemaObject;
46+
content?: {
47+
[media: string]: MediaTypeObject;
48+
};
49+
};
50+
51+
export type SchemaObject = ArraySchemaObject | NonArraySchemaObject;
52+
interface ArraySchemaObject extends BaseSchemaObject {
53+
type: OpenAPIV3.ArraySchemaObjectType;
54+
items: SchemaObject;
55+
}
56+
interface NonArraySchemaObject extends BaseSchemaObject {
57+
type?: OpenAPIV3.NonArraySchemaObjectType;
58+
}
59+
type BaseSchemaObject = Omit<
60+
OpenAPIV3.BaseSchemaObject,
61+
"additionalProperties" | "properties" | "allOf" | "oneOf" | "anyOf" | "not"
62+
> & {
63+
additionalProperties?: boolean | SchemaObject;
64+
properties?: {
65+
[name: string]: SchemaObject;
66+
};
67+
allOf?: SchemaObject[];
68+
oneOf?: SchemaObject[];
69+
anyOf?: SchemaObject[];
70+
not?: SchemaObject;
71+
};
72+
73+
type MediaTypeObject = Omit<
74+
OpenAPIV3.MediaTypeObject,
75+
"schema" | "encoding"
76+
> & {
77+
schema?: SchemaObject;
78+
encoding?: {
79+
[media: string]: EncodingObject;
80+
};
81+
};
82+
type EncodingObject = Omit<OpenAPIV3.EncodingObject, "headers"> & {
83+
headers?: {
84+
[header: string]: HeaderObject;
85+
};
86+
};
87+
type RequestBodyObject = Omit<OpenAPIV3.RequestBodyObject, "content"> & {
88+
content: {
89+
[media: string]: MediaTypeObject;
90+
};
91+
};
92+
interface ResponsesObject {
93+
[code: string]: ResponseObject;
94+
}
95+
type ResponseObject = Omit<OpenAPIV3.ResponseObject, "headers" | "content"> & {
96+
headers?: {
97+
[header: string]: HeaderObject;
98+
};
99+
content?: {
100+
[media: string]: MediaTypeObject;
101+
};
102+
};
103+
104+
interface CallbackObject {
105+
[url: string]: PathItemObject;
106+
}
107+
108+
type ComponentsObject = Omit<
109+
OpenAPIV3.ComponentsObject,
110+
| "schemas"
111+
| "responses"
112+
| "parameters"
113+
| "requestBodies"
114+
| "headers"
115+
| "callbacks"
116+
> & {
117+
schemas?: {
118+
[key: string]: SchemaObject;
119+
};
120+
responses?: {
121+
[key: string]: ResponseObject;
122+
};
123+
parameters?: {
124+
[key: string]: ParameterObject;
125+
};
126+
requestBodies?: {
127+
[key: string]: RequestBodyObject;
128+
};
129+
headers?: {
130+
[key: string]: HeaderObject;
131+
};
132+
callbacks?: {
133+
[key: string]: CallbackObject;
134+
};
135+
};

src/openapi3/handleJson.ts

Lines changed: 65 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,23 @@
1-
import { parse as dereference } from "jsonref";
21
import inflection from "inflection";
2+
import type { ParseOptions } from "jsonref";
3+
import { parse } from "jsonref";
4+
import type { OpenAPIV3 } from "openapi-types";
35
import { Field } from "../Field.js";
6+
import type { OperationType } from "../Operation.js";
47
import { Operation } from "../Operation.js";
58
import { Parameter } from "../Parameter.js";
69
import { Resource } from "../Resource.js";
710
import getResourcePaths from "../utils/getResources.js";
11+
import type {
12+
OpenAPIV3Document,
13+
OperationObject,
14+
SchemaObject,
15+
} from "./dereferencedOpenApiv3.js";
816
import getType from "./getType.js";
9-
import type { OpenAPIV3 } from "openapi-types";
10-
import type { OperationType } from "../Operation.js";
11-
12-
function isParameter(
13-
maybeParameter: NonNullable<OpenAPIV3.OperationObject["parameters"]>[number],
14-
) {
15-
return maybeParameter !== undefined && "in" in maybeParameter;
16-
}
17-
18-
function isSchema(
19-
maybeSchema: OpenAPIV3.MediaTypeObject["schema"],
20-
): maybeSchema is OpenAPIV3.SchemaObject {
21-
// Type predicate can't be inferred because all properties of SchemaObject
22-
// type are optional, so we need to check for absence of $ref, but negated
23-
// `in` checks can't infer the type.
24-
return maybeSchema !== undefined && !("$ref" in maybeSchema);
25-
}
26-
27-
function isResponse(
28-
maybeResponse: OpenAPIV3.ResponsesObject[string] | undefined,
29-
) {
30-
return maybeResponse !== undefined && "description" in maybeResponse;
31-
}
32-
33-
function isRequestBody(
34-
maybeRequestBody: OpenAPIV3.OperationObject["requestBody"],
35-
) {
36-
return maybeRequestBody !== undefined && "content" in maybeRequestBody;
37-
}
38-
39-
function getSchemaFromEditOperation(
40-
editOperation: OpenAPIV3.OperationObject | undefined,
41-
) {
42-
if (
43-
isRequestBody(editOperation?.requestBody) &&
44-
isSchema(editOperation.requestBody.content?.["application/json"]?.schema)
45-
) {
46-
return editOperation.requestBody.content["application/json"].schema;
47-
}
48-
49-
return null;
50-
}
51-
52-
function getSchemaFromShowOperation(
53-
showOperation: OpenAPIV3.OperationObject | undefined,
54-
document: OpenAPIV3.Document,
55-
title: string,
56-
) {
57-
if (
58-
isResponse(showOperation?.responses?.["200"]) &&
59-
isSchema(
60-
showOperation.responses["200"]?.content?.["application/json"]?.schema,
61-
)
62-
) {
63-
return showOperation.responses["200"].content["application/json"].schema;
64-
}
65-
66-
if (isSchema(document.components?.schemas?.[title])) {
67-
return document.components.schemas[title];
68-
}
69-
70-
return null;
71-
}
7217

7318
export function removeTrailingSlash(url: string): string {
7419
if (url.endsWith("/")) {
75-
url = url.slice(0, -1);
20+
return url.slice(0, -1);
7621
}
7722
return url;
7823
}
@@ -101,45 +46,47 @@ function mergeResources(resourceA: Resource, resourceB: Resource) {
10146
return resourceA;
10247
}
10348

49+
function buildEnumObject(enumArray: SchemaObject["enum"]) {
50+
if (!enumArray) {
51+
return null;
52+
}
53+
return Object.fromEntries(
54+
// Object.values is used because the array is annotated: it contains the __meta symbol used by jsonref.
55+
Object.values(enumArray).map((enumValue) => [
56+
typeof enumValue === "string"
57+
? inflection.humanize(enumValue)
58+
: enumValue,
59+
enumValue,
60+
]),
61+
);
62+
}
63+
64+
function getArrayType(property: SchemaObject) {
65+
if (property.type !== "array") {
66+
return null;
67+
}
68+
return getType(property.items.type || "string", property.items.format);
69+
}
70+
10471
function buildResourceFromSchema(
105-
schema: OpenAPIV3.SchemaObject,
72+
schema: SchemaObject,
10673
name: string,
10774
title: string,
10875
url: string,
10976
) {
11077
const { description = "", properties = {} } = schema;
111-
const fieldNames = Object.keys(properties);
11278
const requiredFields = schema.required || [];
113-
79+
const fields: Field[] = [];
11480
const readableFields: Field[] = [];
11581
const writableFields: Field[] = [];
11682

117-
const fields = fieldNames.map((fieldName) => {
118-
const property = properties[fieldName] as OpenAPIV3.SchemaObject;
119-
120-
const type = getType(property.type || "string", property.format);
83+
for (const [fieldName, property] of Object.entries(properties)) {
12184
const field = new Field(fieldName, {
12285
id: null,
12386
range: null,
124-
type,
125-
arrayType:
126-
type === "array" && "items" in property
127-
? getType(
128-
(property.items as OpenAPIV3.SchemaObject).type || "string",
129-
(property.items as OpenAPIV3.SchemaObject).format,
130-
)
131-
: null,
132-
enum: property.enum
133-
? Object.fromEntries(
134-
// Object.values is used because the array is annotated: it contains the __meta symbol used by jsonref.
135-
Object.values<string | number>(property.enum).map((enumValue) => [
136-
typeof enumValue === "string"
137-
? inflection.humanize(enumValue)
138-
: enumValue,
139-
enumValue,
140-
]),
141-
)
142-
: null,
87+
type: getType(property.type || "string", property.format),
88+
arrayType: getArrayType(property),
89+
enum: buildEnumObject(property.enum),
14390
reference: null,
14491
embedded: null,
14592
nullable: property.nullable || false,
@@ -153,9 +100,8 @@ function buildResourceFromSchema(
153100
if (!property.readOnly) {
154101
writableFields.push(field);
155102
}
156-
157-
return field;
158-
});
103+
fields.push(field);
104+
}
159105

160106
return new Resource(name, url, {
161107
id: null,
@@ -173,14 +119,21 @@ function buildResourceFromSchema(
173119
function buildOperationFromPathItem(
174120
httpMethod: `${OpenAPIV3.HttpMethods}`,
175121
operationType: OperationType,
176-
pathItem: OpenAPIV3.OperationObject,
122+
pathItem: OperationObject,
177123
): Operation {
178124
return new Operation(pathItem.summary || operationType, operationType, {
179125
method: httpMethod.toUpperCase(),
180126
deprecated: !!pathItem.deprecated,
181127
});
182128
}
183129

130+
function dereferenceOpenAPIV3(
131+
response: OpenAPIV3.Document,
132+
options: ParseOptions,
133+
): Promise<OpenAPIV3Document> {
134+
return parse(response, options);
135+
}
136+
184137
/*
185138
Assumptions:
186139
RESTful APIs typically have two paths per resources: a `/noun` path and a
@@ -195,9 +148,9 @@ export default async function handleJson(
195148
response: OpenAPIV3.Document,
196149
entrypointUrl: string,
197150
): Promise<Resource[]> {
198-
const document = (await dereference(response, {
151+
const document = await dereferenceOpenAPIV3(response, {
199152
scope: entrypointUrl,
200-
})) as OpenAPIV3.Document;
153+
});
201154

202155
const paths = getResourcePaths(document.paths);
203156

@@ -227,14 +180,12 @@ export default async function handleJson(
227180
if (!showOperation && !editOperation) {
228181
continue;
229182
}
183+
const showSchema =
184+
showOperation?.responses?.["200"]?.content?.["application/json"]
185+
?.schema || document.components?.schemas?.[title];
230186

231-
const showSchema = getSchemaFromShowOperation(
232-
showOperation,
233-
document,
234-
title,
235-
);
236-
237-
const editSchema = getSchemaFromEditOperation(editOperation);
187+
const editSchema =
188+
editOperation?.requestBody?.content?.["application/json"]?.schema || null;
238189

239190
if (!showSchema && !editSchema) {
240191
continue;
@@ -282,22 +233,16 @@ export default async function handleJson(
282233
];
283234

284235
if (listOperation && listOperation.parameters) {
285-
resource.parameters = listOperation.parameters
286-
.filter(isParameter)
287-
.map(
288-
(parameter) =>
289-
new Parameter(
290-
parameter.name,
291-
parameter.schema &&
292-
isSchema(parameter.schema) &&
293-
parameter.schema.type
294-
? getType(parameter.schema.type)
295-
: null,
296-
parameter.required || false,
297-
parameter.description || "",
298-
parameter.deprecated,
299-
),
300-
);
236+
resource.parameters = listOperation.parameters.map(
237+
(parameter) =>
238+
new Parameter(
239+
parameter.name,
240+
parameter.schema?.type ? getType(parameter.schema.type) : null,
241+
parameter.required || false,
242+
parameter.description || "",
243+
parameter.deprecated,
244+
),
245+
);
301246
}
302247

303248
resources.push(resource);

0 commit comments

Comments
 (0)