@f3liz/rescript-autogen-openapi 0.1.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 +339 -0
- package/README.md +98 -0
- package/lib/es6/src/Codegen.mjs +423 -0
- package/lib/es6/src/Types.mjs +20 -0
- package/lib/es6/src/core/CodegenUtils.mjs +186 -0
- package/lib/es6/src/core/DocOverride.mjs +399 -0
- package/lib/es6/src/core/FileSystem.mjs +78 -0
- package/lib/es6/src/core/IRBuilder.mjs +201 -0
- package/lib/es6/src/core/OpenAPIParser.mjs +168 -0
- package/lib/es6/src/core/Pipeline.mjs +150 -0
- package/lib/es6/src/core/ReferenceResolver.mjs +41 -0
- package/lib/es6/src/core/Result.mjs +378 -0
- package/lib/es6/src/core/SchemaIR.mjs +355 -0
- package/lib/es6/src/core/SchemaIRParser.mjs +490 -0
- package/lib/es6/src/core/SchemaRefResolver.mjs +146 -0
- package/lib/es6/src/core/SchemaRegistry.mjs +92 -0
- package/lib/es6/src/core/SpecDiffer.mjs +251 -0
- package/lib/es6/src/core/SpecMerger.mjs +237 -0
- package/lib/es6/src/generators/ComponentSchemaGenerator.mjs +125 -0
- package/lib/es6/src/generators/DiffReportGenerator.mjs +155 -0
- package/lib/es6/src/generators/EndpointGenerator.mjs +172 -0
- package/lib/es6/src/generators/IRToSuryGenerator.mjs +233 -0
- package/lib/es6/src/generators/IRToTypeGenerator.mjs +241 -0
- package/lib/es6/src/generators/IRToTypeScriptGenerator.mjs +143 -0
- package/lib/es6/src/generators/ModuleGenerator.mjs +285 -0
- package/lib/es6/src/generators/SchemaCodeGenerator.mjs +77 -0
- package/lib/es6/src/generators/ThinWrapperGenerator.mjs +97 -0
- package/lib/es6/src/generators/TypeScriptDtsGenerator.mjs +172 -0
- package/lib/es6/src/generators/TypeScriptWrapperGenerator.mjs +145 -0
- package/lib/es6/src/types/CodegenError.mjs +79 -0
- package/lib/es6/src/types/Config.mjs +42 -0
- package/lib/es6/src/types/GenerationContext.mjs +24 -0
- package/package.json +44 -0
- package/rescript.json +20 -0
- package/src/Codegen.res +222 -0
- package/src/Types.res +195 -0
- package/src/core/CodegenUtils.res +130 -0
- package/src/core/DocOverride.res +504 -0
- package/src/core/FileSystem.res +62 -0
- package/src/core/IRBuilder.res +66 -0
- package/src/core/OpenAPIParser.res +144 -0
- package/src/core/Pipeline.res +51 -0
- package/src/core/ReferenceResolver.res +41 -0
- package/src/core/Result.res +187 -0
- package/src/core/SchemaIR.res +258 -0
- package/src/core/SchemaIRParser.res +360 -0
- package/src/core/SchemaRefResolver.res +143 -0
- package/src/core/SchemaRegistry.res +107 -0
- package/src/core/SpecDiffer.res +270 -0
- package/src/core/SpecMerger.res +245 -0
- package/src/generators/ComponentSchemaGenerator.res +127 -0
- package/src/generators/DiffReportGenerator.res +152 -0
- package/src/generators/EndpointGenerator.res +172 -0
- package/src/generators/IRToSuryGenerator.res +199 -0
- package/src/generators/IRToTypeGenerator.res +199 -0
- package/src/generators/IRToTypeScriptGenerator.res +72 -0
- package/src/generators/ModuleGenerator.res +362 -0
- package/src/generators/SchemaCodeGenerator.res +83 -0
- package/src/generators/ThinWrapperGenerator.res +124 -0
- package/src/generators/TypeScriptDtsGenerator.res +193 -0
- package/src/generators/TypeScriptWrapperGenerator.res +166 -0
- package/src/types/CodegenError.res +82 -0
- package/src/types/Config.res +89 -0
- package/src/types/GenerationContext.res +23 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
// SchemaIRParser.res - Parse JSON Schema to Unified IR
|
|
4
|
+
|
|
5
|
+
// Helper to convert raw JSON type string to our variant
|
|
6
|
+
// This is needed because Obj.magic from JSON gives us raw strings like "string", "object", etc.
|
|
7
|
+
// but our variant constructors compile to "String", "Object", etc. in JS
|
|
8
|
+
let parseTypeString = (rawType: Types.jsonSchemaType): Types.jsonSchemaType => {
|
|
9
|
+
// The rawType might actually be a raw string from JSON, so we need to handle that
|
|
10
|
+
// We use Obj.magic to get the underlying JS value and check it
|
|
11
|
+
let rawStr: string = Obj.magic(rawType)
|
|
12
|
+
switch rawStr {
|
|
13
|
+
| "string" => Types.String
|
|
14
|
+
| "number" => Types.Number
|
|
15
|
+
| "integer" => Types.Integer
|
|
16
|
+
| "boolean" => Types.Boolean
|
|
17
|
+
| "object" => Types.Object
|
|
18
|
+
| "null" => Types.Null
|
|
19
|
+
| "array" => Types.Array(Types.Unknown)
|
|
20
|
+
| _ => {
|
|
21
|
+
// It might already be a proper variant (like when recursively constructed)
|
|
22
|
+
// In that case, rawStr would be "String", "Number", etc.
|
|
23
|
+
switch rawStr {
|
|
24
|
+
| "String" => Types.String
|
|
25
|
+
| "Number" => Types.Number
|
|
26
|
+
| "Integer" => Types.Integer
|
|
27
|
+
| "Boolean" => Types.Boolean
|
|
28
|
+
| "Object" => Types.Object
|
|
29
|
+
| "Null" => Types.Null
|
|
30
|
+
| "Unknown" => Types.Unknown
|
|
31
|
+
| _ => Types.Unknown
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Parsing context to collect warnings
|
|
38
|
+
type parsingContext = {
|
|
39
|
+
mutable warnings: array<Types.warning>,
|
|
40
|
+
path: string,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let addWarning = (ctx: parsingContext, warning: Types.warning): unit => {
|
|
44
|
+
ctx.warnings->Array.push(warning)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Convert JSON Schema to IR with depth limit to prevent infinite recursion
|
|
48
|
+
let rec parseJsonSchemaWithContext = (
|
|
49
|
+
~ctx: parsingContext,
|
|
50
|
+
~depth=0,
|
|
51
|
+
schema: Types.jsonSchema,
|
|
52
|
+
): SchemaIR.irType => {
|
|
53
|
+
// Safety: Prevent infinite recursion on circular schemas
|
|
54
|
+
if depth > 30 {
|
|
55
|
+
addWarning(
|
|
56
|
+
ctx,
|
|
57
|
+
DepthLimitReached({
|
|
58
|
+
depth: depth,
|
|
59
|
+
path: ctx.path,
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
SchemaIR.Unknown
|
|
63
|
+
} else {
|
|
64
|
+
// Handle $ref first
|
|
65
|
+
switch schema.ref {
|
|
66
|
+
| Some(ref) => SchemaIR.Reference(ref)
|
|
67
|
+
| None => {
|
|
68
|
+
// Check if nullable
|
|
69
|
+
let isNullable = schema.nullable->Option.getOr(false)
|
|
70
|
+
|
|
71
|
+
// Normalize the type field (raw JSON strings like "string" -> variant String)
|
|
72
|
+
let normalizedType = schema.type_->Option.map(parseTypeString)
|
|
73
|
+
|
|
74
|
+
// Parse base type
|
|
75
|
+
let baseType = switch normalizedType {
|
|
76
|
+
| Some(Types.String) => {
|
|
77
|
+
let constraints: SchemaIR.stringConstraints = {
|
|
78
|
+
minLength: schema.minLength,
|
|
79
|
+
maxLength: schema.maxLength,
|
|
80
|
+
pattern: schema.pattern,
|
|
81
|
+
}
|
|
82
|
+
SchemaIR.String({constraints: constraints})
|
|
83
|
+
}
|
|
84
|
+
| Some(Types.Number) => {
|
|
85
|
+
let constraints: SchemaIR.numberConstraints = {
|
|
86
|
+
minimum: schema.minimum,
|
|
87
|
+
maximum: schema.maximum,
|
|
88
|
+
multipleOf: None, // Not in jsonSchema type
|
|
89
|
+
}
|
|
90
|
+
SchemaIR.Number({constraints: constraints})
|
|
91
|
+
}
|
|
92
|
+
| Some(Types.Integer) => {
|
|
93
|
+
let constraints: SchemaIR.numberConstraints = {
|
|
94
|
+
minimum: schema.minimum,
|
|
95
|
+
maximum: schema.maximum,
|
|
96
|
+
multipleOf: None, // Not in jsonSchema type
|
|
97
|
+
}
|
|
98
|
+
SchemaIR.Integer({constraints: constraints})
|
|
99
|
+
}
|
|
100
|
+
| Some(Types.Boolean) => SchemaIR.Boolean
|
|
101
|
+
| Some(Types.Null) => SchemaIR.Null
|
|
102
|
+
| Some(Types.Array(_)) => {
|
|
103
|
+
let items = switch schema.items {
|
|
104
|
+
| None => SchemaIR.Unknown
|
|
105
|
+
| Some(itemSchema) =>
|
|
106
|
+
parseJsonSchemaWithContext(~ctx, ~depth=depth + 1, itemSchema)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let constraints: SchemaIR.arrayConstraints = {
|
|
110
|
+
minItems: None, // Not in current jsonSchema type
|
|
111
|
+
maxItems: None, // Not in current jsonSchema type
|
|
112
|
+
uniqueItems: false,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
SchemaIR.Array({items, constraints})
|
|
116
|
+
}
|
|
117
|
+
| Some(Types.Object) => {
|
|
118
|
+
// Check if this is an allOf composition (common in OpenAPI)
|
|
119
|
+
switch schema.allOf {
|
|
120
|
+
| Some(schemas) => {
|
|
121
|
+
// allOf with type: "object" - parse as intersection
|
|
122
|
+
let types = schemas->Array.map(s => parseJsonSchemaWithContext(~ctx, ~depth=depth + 1, s))
|
|
123
|
+
SchemaIR.Intersection(types)
|
|
124
|
+
}
|
|
125
|
+
| None => {
|
|
126
|
+
// Regular object type - parse properties
|
|
127
|
+
let properties = switch schema.properties {
|
|
128
|
+
| None => []
|
|
129
|
+
| Some(propsDict) => {
|
|
130
|
+
let required = schema.required->Option.getOr([])
|
|
131
|
+
Dict.toArray(propsDict)->Array.map(((name, propSchema)) => {
|
|
132
|
+
let isRequired = required->Array.includes(name)
|
|
133
|
+
let propType = parseJsonSchemaWithContext(~ctx, ~depth=depth + 1, propSchema)
|
|
134
|
+
(name, propType, isRequired)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// additionalProperties not in current jsonSchema type
|
|
140
|
+
let additionalProperties = None
|
|
141
|
+
|
|
142
|
+
SchemaIR.Object({
|
|
143
|
+
properties,
|
|
144
|
+
additionalProperties,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
| Some(Types.Unknown) => SchemaIR.Unknown
|
|
150
|
+
| None => {
|
|
151
|
+
// No type specified, check for enum, properties, or combinators
|
|
152
|
+
switch (schema.enum, schema.properties, schema.allOf, schema.oneOf, schema.anyOf) {
|
|
153
|
+
| (Some(enumValues), _, _, _, _) => {
|
|
154
|
+
// Enum - convert to union of literals
|
|
155
|
+
let literals = enumValues->Array.map(value => {
|
|
156
|
+
switch value {
|
|
157
|
+
| String(str) => SchemaIR.Literal(SchemaIR.StringLiteral(str))
|
|
158
|
+
| Number(num) => SchemaIR.Literal(SchemaIR.NumberLiteral(num))
|
|
159
|
+
| Boolean(b) => SchemaIR.Literal(SchemaIR.BooleanLiteral(b))
|
|
160
|
+
| Null => SchemaIR.Literal(SchemaIR.NullLiteral)
|
|
161
|
+
| _ => SchemaIR.Unknown
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
SchemaIR.Union(literals)
|
|
165
|
+
}
|
|
166
|
+
| (_, Some(_), _, _, _) => {
|
|
167
|
+
// Has properties, treat as object
|
|
168
|
+
parseJsonSchemaWithContext(~ctx, ~depth=depth + 1, {...schema, type_: Some(Object)})
|
|
169
|
+
}
|
|
170
|
+
| (_, _, Some(schemas), _, _) => {
|
|
171
|
+
// allOf - intersection
|
|
172
|
+
let types = schemas->Array.map(s => parseJsonSchemaWithContext(~ctx, ~depth=depth + 1, s))
|
|
173
|
+
SchemaIR.Intersection(types)
|
|
174
|
+
}
|
|
175
|
+
| (_, _, _, Some(schemas), _) => {
|
|
176
|
+
// oneOf - union
|
|
177
|
+
let types = schemas->Array.map(s => parseJsonSchemaWithContext(~ctx, ~depth=depth + 1, s))
|
|
178
|
+
SchemaIR.Union(types)
|
|
179
|
+
}
|
|
180
|
+
| (_, _, _, _, Some(schemas)) => {
|
|
181
|
+
// anyOf - union
|
|
182
|
+
let types = schemas->Array.map(s => parseJsonSchemaWithContext(~ctx, ~depth=depth + 1, s))
|
|
183
|
+
SchemaIR.Union(types)
|
|
184
|
+
}
|
|
185
|
+
| _ => SchemaIR.Unknown
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Wrap in Option if nullable
|
|
191
|
+
if isNullable {
|
|
192
|
+
SchemaIR.Option(baseType)
|
|
193
|
+
} else {
|
|
194
|
+
baseType
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Convenience wrapper that creates a context and returns warnings
|
|
202
|
+
let parseJsonSchema = (~depth=0, schema: Types.jsonSchema): (SchemaIR.irType, array<Types.warning>) => {
|
|
203
|
+
let ctx = {warnings: [], path: "root"}
|
|
204
|
+
let irType = parseJsonSchemaWithContext(~ctx, ~depth, schema)
|
|
205
|
+
(irType, ctx.warnings)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Parse a named schema
|
|
209
|
+
let parseNamedSchema = (~name: string, ~schema: Types.jsonSchema): (SchemaIR.namedSchema, array<Types.warning>) => {
|
|
210
|
+
let ctx = {warnings: [], path: `components.schemas.${name}`}
|
|
211
|
+
let type_ = parseJsonSchemaWithContext(~ctx, schema)
|
|
212
|
+
({
|
|
213
|
+
name,
|
|
214
|
+
description: schema.description,
|
|
215
|
+
type_,
|
|
216
|
+
}, ctx.warnings)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Parse all component schemas
|
|
220
|
+
let parseComponentSchemas = (schemas: dict<Types.jsonSchema>): (SchemaIR.schemaContext, array<Types.warning>) => {
|
|
221
|
+
let namedSchemas = Dict.make()
|
|
222
|
+
let allWarnings = []
|
|
223
|
+
|
|
224
|
+
schemas->Dict.toArray->Array.forEach(((name, schema)) => {
|
|
225
|
+
let (namedSchema, warnings) = parseNamedSchema(~name, ~schema)
|
|
226
|
+
Dict.set(namedSchemas, name, namedSchema)
|
|
227
|
+
allWarnings->Array.pushMany(warnings)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
({schemas: namedSchemas}, allWarnings)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Resolve a reference in the context
|
|
234
|
+
let resolveReference = (
|
|
235
|
+
~context: SchemaIR.schemaContext,
|
|
236
|
+
~ref: string,
|
|
237
|
+
): option<SchemaIR.namedSchema> => {
|
|
238
|
+
// Handle #/components/schemas/SchemaName format
|
|
239
|
+
let parts = ref->String.split("/")
|
|
240
|
+
switch parts->Array.get(parts->Array.length - 1) {
|
|
241
|
+
| None => None
|
|
242
|
+
| Some(schemaName) => Dict.get(context.schemas, schemaName)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Inline simple references to reduce indirection
|
|
247
|
+
let rec inlineSimpleReferences = (
|
|
248
|
+
~context: SchemaIR.schemaContext,
|
|
249
|
+
~irType: SchemaIR.irType,
|
|
250
|
+
~depth: int=0,
|
|
251
|
+
~maxDepth: int=2,
|
|
252
|
+
): SchemaIR.irType => {
|
|
253
|
+
if depth >= maxDepth {
|
|
254
|
+
irType
|
|
255
|
+
} else {
|
|
256
|
+
switch irType {
|
|
257
|
+
| SchemaIR.Reference(ref) => {
|
|
258
|
+
switch resolveReference(~context, ~ref) {
|
|
259
|
+
| None => irType
|
|
260
|
+
| Some(schema) => {
|
|
261
|
+
// Only inline if it's a simple type
|
|
262
|
+
if SchemaIR.isSimpleType(schema.type_) {
|
|
263
|
+
inlineSimpleReferences(~context, ~irType=schema.type_, ~depth=depth + 1, ~maxDepth)
|
|
264
|
+
} else {
|
|
265
|
+
irType
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
| SchemaIR.Option(inner) =>
|
|
271
|
+
SchemaIR.Option(inlineSimpleReferences(~context, ~irType=inner, ~depth, ~maxDepth))
|
|
272
|
+
| SchemaIR.Array({items, constraints}) =>
|
|
273
|
+
SchemaIR.Array({
|
|
274
|
+
items: inlineSimpleReferences(~context, ~irType=items, ~depth, ~maxDepth),
|
|
275
|
+
constraints,
|
|
276
|
+
})
|
|
277
|
+
| SchemaIR.Object({properties, additionalProperties}) => {
|
|
278
|
+
let newProperties = properties->Array.map(((name, type_, required)) => {
|
|
279
|
+
(name, inlineSimpleReferences(~context, ~irType=type_, ~depth, ~maxDepth), required)
|
|
280
|
+
})
|
|
281
|
+
let newAdditionalProps = additionalProperties->Option.map(type_ =>
|
|
282
|
+
inlineSimpleReferences(~context, ~irType=type_, ~depth, ~maxDepth)
|
|
283
|
+
)
|
|
284
|
+
SchemaIR.Object({
|
|
285
|
+
properties: newProperties,
|
|
286
|
+
additionalProperties: newAdditionalProps,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
| SchemaIR.Union(types) =>
|
|
290
|
+
SchemaIR.Union(types->Array.map(type_ =>
|
|
291
|
+
inlineSimpleReferences(~context, ~irType=type_, ~depth, ~maxDepth)
|
|
292
|
+
))
|
|
293
|
+
| SchemaIR.Intersection(types) =>
|
|
294
|
+
SchemaIR.Intersection(types->Array.map(type_ =>
|
|
295
|
+
inlineSimpleReferences(~context, ~irType=type_, ~depth, ~maxDepth)
|
|
296
|
+
))
|
|
297
|
+
| other => other
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Optimize IR by simplifying unions, intersections, etc.
|
|
303
|
+
let rec optimizeIR = (irType: SchemaIR.irType): SchemaIR.irType => {
|
|
304
|
+
switch irType {
|
|
305
|
+
| SchemaIR.Union(types) => {
|
|
306
|
+
// Flatten nested unions
|
|
307
|
+
let flattened = types->Array.flatMap(t => {
|
|
308
|
+
switch optimizeIR(t) {
|
|
309
|
+
| SchemaIR.Union(inner) => inner
|
|
310
|
+
| other => [other]
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// Remove duplicates (simple dedup by toString)
|
|
315
|
+
let unique = []
|
|
316
|
+
flattened->Array.forEach(type_ => {
|
|
317
|
+
let typeStr = SchemaIR.toString(type_)
|
|
318
|
+
let exists = unique->Array.some(t => SchemaIR.toString(t) == typeStr)
|
|
319
|
+
if !exists { unique->Array.push(type_) }
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// Simplify single-element unions
|
|
323
|
+
switch unique {
|
|
324
|
+
| [] => SchemaIR.Unknown
|
|
325
|
+
| [single] => single
|
|
326
|
+
| multiple => SchemaIR.Union(multiple)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
| SchemaIR.Intersection(types) => {
|
|
330
|
+
// Flatten nested intersections
|
|
331
|
+
let flattened = types->Array.flatMap(t => {
|
|
332
|
+
switch optimizeIR(t) {
|
|
333
|
+
| SchemaIR.Intersection(inner) => inner
|
|
334
|
+
| other => [other]
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// Simplify single-element intersections
|
|
339
|
+
switch flattened {
|
|
340
|
+
| [] => SchemaIR.Unknown
|
|
341
|
+
| [single] => single
|
|
342
|
+
| multiple => SchemaIR.Intersection(multiple)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
| SchemaIR.Option(inner) => SchemaIR.Option(optimizeIR(inner))
|
|
346
|
+
| SchemaIR.Array({items, constraints}) =>
|
|
347
|
+
SchemaIR.Array({items: optimizeIR(items), constraints})
|
|
348
|
+
| SchemaIR.Object({properties, additionalProperties}) => {
|
|
349
|
+
let newProperties = properties->Array.map(((name, type_, required)) => {
|
|
350
|
+
(name, optimizeIR(type_), required)
|
|
351
|
+
})
|
|
352
|
+
let newAdditionalProps = additionalProperties->Option.map(optimizeIR)
|
|
353
|
+
SchemaIR.Object({
|
|
354
|
+
properties: newProperties,
|
|
355
|
+
additionalProperties: newAdditionalProps,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
| other => other
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
// SchemaRefResolver.res - Bindings to @readme/openapi-parser
|
|
4
|
+
|
|
5
|
+
// Parser options type
|
|
6
|
+
type parserOptions = {
|
|
7
|
+
timeoutMs?: int,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// External bindings to @readme/openapi-parser v5.x
|
|
11
|
+
@module("@readme/openapi-parser")
|
|
12
|
+
external parse: (string, ~options: parserOptions=?) => promise<JSON.t> = "parse"
|
|
13
|
+
|
|
14
|
+
@module("@readme/openapi-parser")
|
|
15
|
+
external bundle: (string, ~options: parserOptions=?) => promise<JSON.t> = "bundle"
|
|
16
|
+
|
|
17
|
+
@module("@readme/openapi-parser")
|
|
18
|
+
external dereference: (string, ~options: parserOptions=?) => promise<JSON.t> = "dereference"
|
|
19
|
+
|
|
20
|
+
@module("@readme/openapi-parser")
|
|
21
|
+
external validate: (string, ~options: parserOptions=?) => promise<JSON.t> = "validate"
|
|
22
|
+
|
|
23
|
+
// Helper to convert JSON to OpenAPISpec
|
|
24
|
+
let jsonToSpec = (json: JSON.t): result<Types.openAPISpec, string> => {
|
|
25
|
+
switch json->JSON.Decode.object {
|
|
26
|
+
| Some(obj) => {
|
|
27
|
+
// Convert to our OpenAPISpec type
|
|
28
|
+
let pathsDict = obj
|
|
29
|
+
->Dict.get("paths")
|
|
30
|
+
->Option.flatMap(JSON.Decode.object)
|
|
31
|
+
->Option.getOr(Dict.make())
|
|
32
|
+
|
|
33
|
+
let openAPISpec: Types.openAPISpec = {
|
|
34
|
+
openapi: obj
|
|
35
|
+
->Dict.get("openapi")
|
|
36
|
+
->Option.flatMap(JSON.Decode.string)
|
|
37
|
+
->Option.getOr("3.1.0"),
|
|
38
|
+
info: {
|
|
39
|
+
let infoObj = obj
|
|
40
|
+
->Dict.get("info")
|
|
41
|
+
->Option.flatMap(JSON.Decode.object)
|
|
42
|
+
->Option.getOr(Dict.make())
|
|
43
|
+
{
|
|
44
|
+
title: infoObj
|
|
45
|
+
->Dict.get("title")
|
|
46
|
+
->Option.flatMap(JSON.Decode.string)
|
|
47
|
+
->Option.getOr("Untitled API"),
|
|
48
|
+
version: infoObj
|
|
49
|
+
->Dict.get("version")
|
|
50
|
+
->Option.flatMap(JSON.Decode.string)
|
|
51
|
+
->Option.getOr("0.0.0"),
|
|
52
|
+
description: infoObj
|
|
53
|
+
->Dict.get("description")
|
|
54
|
+
->Option.flatMap(JSON.Decode.string),
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
paths: pathsDict
|
|
58
|
+
->Dict.toArray
|
|
59
|
+
->Array.map(((key, _value)) => (key, Obj.magic(_value)))
|
|
60
|
+
->Dict.fromArray,
|
|
61
|
+
components: obj
|
|
62
|
+
->Dict.get("components")
|
|
63
|
+
->Option.map(_comp => ({
|
|
64
|
+
schemas: _comp
|
|
65
|
+
->JSON.Decode.object
|
|
66
|
+
->Option.flatMap(c => c->Dict.get("schemas"))
|
|
67
|
+
->Option.flatMap(JSON.Decode.object)
|
|
68
|
+
->Option.map(schemas =>
|
|
69
|
+
schemas
|
|
70
|
+
->Dict.toArray
|
|
71
|
+
->Array.map(((key, value)) => (key, Obj.magic(value)))
|
|
72
|
+
->Dict.fromArray
|
|
73
|
+
),
|
|
74
|
+
}: Types.components)),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Ok(openAPISpec)
|
|
78
|
+
}
|
|
79
|
+
| None => Error("Invalid OpenAPI spec: root is not an object")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve a spec from source
|
|
84
|
+
let resolve = async (source: string, ~timeout: option<int>=?): result<Types.openAPISpec, string> => {
|
|
85
|
+
try {
|
|
86
|
+
// Use bundle to combine all external refs while keeping internal $refs intact
|
|
87
|
+
let options = {timeoutMs: timeout->Option.getOr(60000)}
|
|
88
|
+
let resolved = await bundle(source, ~options)
|
|
89
|
+
|
|
90
|
+
jsonToSpec(resolved)
|
|
91
|
+
} catch {
|
|
92
|
+
| JsExn(err) => {
|
|
93
|
+
let message = err->JsExn.message->Option.getOr("Unknown error resolving spec")
|
|
94
|
+
Error(`Failed to resolve spec: ${message}`)
|
|
95
|
+
}
|
|
96
|
+
| _ => Error("Unknown error resolving spec")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Parse an OpenAPI spec without dereferencing
|
|
101
|
+
let parseOnly = async (source: string, ~timeout: option<int>=?): result<Types.openAPISpec, string> => {
|
|
102
|
+
try {
|
|
103
|
+
let options = {timeoutMs: timeout->Option.getOr(60000)}
|
|
104
|
+
let parsed = await parse(source, ~options)
|
|
105
|
+
jsonToSpec(parsed)
|
|
106
|
+
} catch {
|
|
107
|
+
| JsExn(err) => {
|
|
108
|
+
let message = err->JsExn.message->Option.getOr("Unknown error parsing spec")
|
|
109
|
+
Error(`Failed to parse spec: ${message}`)
|
|
110
|
+
}
|
|
111
|
+
| _ => Error("Unknown error parsing spec")
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Bundle an OpenAPI spec (combines all external references into a single file)
|
|
116
|
+
let bundleSpec = async (source: string, ~timeout: option<int>=?): result<JSON.t, string> => {
|
|
117
|
+
try {
|
|
118
|
+
let options = {timeoutMs: timeout->Option.getOr(60000)}
|
|
119
|
+
let bundled = await bundle(source, ~options)
|
|
120
|
+
Ok(bundled)
|
|
121
|
+
} catch {
|
|
122
|
+
| JsExn(err) => {
|
|
123
|
+
let message = err->JsExn.message->Option.getOr("Unknown error bundling spec")
|
|
124
|
+
Error(`Failed to bundle spec: ${message}`)
|
|
125
|
+
}
|
|
126
|
+
| _ => Error("Unknown error bundling spec")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validate an OpenAPI spec
|
|
131
|
+
let validateSpec = async (source: string, ~timeout: option<int>=?): result<Types.openAPISpec, string> => {
|
|
132
|
+
try {
|
|
133
|
+
let options = {timeoutMs: timeout->Option.getOr(60000)}
|
|
134
|
+
let validated = await validate(source, ~options)
|
|
135
|
+
jsonToSpec(validated)
|
|
136
|
+
} catch {
|
|
137
|
+
| JsExn(err) => {
|
|
138
|
+
let message = err->JsExn.message->Option.getOr("Unknown error validating spec")
|
|
139
|
+
Error(`Failed to validate spec: ${message}`)
|
|
140
|
+
}
|
|
141
|
+
| _ => Error("Unknown error validating spec")
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
// SchemaRegistry.res - Registry for resolving $ref references in OpenAPI specs
|
|
4
|
+
//
|
|
5
|
+
// This module provides a way to:
|
|
6
|
+
// 1. Store all component schemas from an OpenAPI spec
|
|
7
|
+
// 2. Resolve $ref strings to their underlying jsonSchema
|
|
8
|
+
// 3. Track which schemas are being visited to detect circular references
|
|
9
|
+
|
|
10
|
+
open Types
|
|
11
|
+
|
|
12
|
+
// The registry type
|
|
13
|
+
type t = {
|
|
14
|
+
// All schemas from components.schemas
|
|
15
|
+
schemas: Dict.t<jsonSchema>,
|
|
16
|
+
// Track visited schemas during resolution to detect cycles
|
|
17
|
+
mutable visiting: array<string>,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Create a registry from an OpenAPI spec
|
|
21
|
+
let fromSpec = (spec: openAPISpec): t => {
|
|
22
|
+
let schemas = switch spec.components {
|
|
23
|
+
| None => Dict.make()
|
|
24
|
+
| Some(components) => {
|
|
25
|
+
switch components.schemas {
|
|
26
|
+
| None => Dict.make()
|
|
27
|
+
| Some(s) => s
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
schemas,
|
|
34
|
+
visiting: [],
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create an empty registry
|
|
39
|
+
let empty = (): t => {
|
|
40
|
+
{
|
|
41
|
+
schemas: Dict.make(),
|
|
42
|
+
visiting: [],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Extract schema name from a $ref string
|
|
47
|
+
// e.g., "#/components/schemas/Note" -> Some("Note")
|
|
48
|
+
let extractSchemaName = (ref: string): option<string> => {
|
|
49
|
+
let prefix = "#/components/schemas/"
|
|
50
|
+
if String.startsWith(ref, prefix) {
|
|
51
|
+
Some(String.slice(ref, ~start=String.length(prefix)))
|
|
52
|
+
} else {
|
|
53
|
+
None
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if a schema name is currently being visited (cycle detection)
|
|
58
|
+
let isVisiting = (registry: t, name: string): bool => {
|
|
59
|
+
registry.visiting->Array.includes(name)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Start visiting a schema (for cycle detection)
|
|
63
|
+
let startVisiting = (registry: t, name: string): unit => {
|
|
64
|
+
registry.visiting = Array.concat(registry.visiting, [name])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Stop visiting a schema
|
|
68
|
+
let stopVisiting = (registry: t, name: string): unit => {
|
|
69
|
+
registry.visiting = registry.visiting->Array.filter(n => n != name)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Look up a schema by name
|
|
73
|
+
let getSchema = (registry: t, name: string): option<jsonSchema> => {
|
|
74
|
+
Dict.get(registry.schemas, name)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Look up a schema by $ref string
|
|
78
|
+
let resolveRef = (registry: t, ref: string): option<jsonSchema> => {
|
|
79
|
+
switch extractSchemaName(ref) {
|
|
80
|
+
| None => None
|
|
81
|
+
| Some(name) => getSchema(registry, name)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Get all schema names
|
|
86
|
+
let getSchemaNames = (registry: t): array<string> => {
|
|
87
|
+
Dict.keysToArray(registry.schemas)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if a schema exists
|
|
91
|
+
let hasSchema = (registry: t, name: string): bool => {
|
|
92
|
+
Dict.get(registry.schemas, name)->Option.isSome
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Add a schema to the registry
|
|
96
|
+
let addSchema = (registry: t, name: string, schema: jsonSchema): unit => {
|
|
97
|
+
Dict.set(registry.schemas, name, schema)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Merge schemas from another registry
|
|
101
|
+
let merge = (registry: t, other: t): unit => {
|
|
102
|
+
Dict.toArray(other.schemas)->Array.forEach(((name, schema)) => {
|
|
103
|
+
if !hasSchema(registry, name) {
|
|
104
|
+
addSchema(registry, name, schema)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|