@effect/openapi-generator 4.0.0-beta.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/dist/JsonSchemaGenerator.d.ts +8 -0
- package/dist/JsonSchemaGenerator.d.ts.map +1 -0
- package/dist/JsonSchemaGenerator.js +75 -0
- package/dist/JsonSchemaGenerator.js.map +1 -0
- package/dist/OpenApiGenerator.d.ts +32 -0
- package/dist/OpenApiGenerator.d.ts.map +1 -0
- package/dist/OpenApiGenerator.js +206 -0
- package/dist/OpenApiGenerator.js.map +1 -0
- package/dist/OpenApiPatch.d.ts +296 -0
- package/dist/OpenApiPatch.d.ts.map +1 -0
- package/dist/OpenApiPatch.js +448 -0
- package/dist/OpenApiPatch.js.map +1 -0
- package/dist/OpenApiTransformer.d.ts +24 -0
- package/dist/OpenApiTransformer.d.ts.map +1 -0
- package/dist/OpenApiTransformer.js +740 -0
- package/dist/OpenApiTransformer.js.map +1 -0
- package/dist/ParsedOperation.d.ts +29 -0
- package/dist/ParsedOperation.d.ts.map +1 -0
- package/dist/ParsedOperation.js +13 -0
- package/dist/ParsedOperation.js.map +1 -0
- package/dist/Utils.d.ts +6 -0
- package/dist/Utils.d.ts.map +1 -0
- package/dist/Utils.js +42 -0
- package/dist/Utils.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +7 -0
- package/dist/bin.js.map +1 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +47 -0
- package/dist/main.js.map +1 -0
- package/package.json +67 -0
- package/src/JsonSchemaGenerator.ts +94 -0
- package/src/OpenApiGenerator.ts +309 -0
- package/src/OpenApiPatch.ts +514 -0
- package/src/OpenApiTransformer.ts +954 -0
- package/src/ParsedOperation.ts +43 -0
- package/src/Utils.ts +50 -0
- package/src/bin.ts +10 -0
- package/src/main.ts +68 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import type * as JsonSchema from "effect/JsonSchema"
|
|
3
|
+
import * as Layer from "effect/Layer"
|
|
4
|
+
import * as Predicate from "effect/Predicate"
|
|
5
|
+
import * as ServiceMap from "effect/ServiceMap"
|
|
6
|
+
import * as String from "effect/String"
|
|
7
|
+
import type { OpenAPISpec, OpenAPISpecMethodName, OpenAPISpecPathItem } from "effect/unstable/httpapi/OpenApi"
|
|
8
|
+
import SwaggerToOpenApi from "swagger2openapi"
|
|
9
|
+
import * as JsonSchemaGenerator from "./JsonSchemaGenerator.ts"
|
|
10
|
+
import * as OpenApiTransformer from "./OpenApiTransformer.ts"
|
|
11
|
+
import * as ParsedOperation from "./ParsedOperation.ts"
|
|
12
|
+
import * as Utils from "./Utils.ts"
|
|
13
|
+
|
|
14
|
+
export class OpenApiGenerator extends ServiceMap.Service<
|
|
15
|
+
OpenApiGenerator,
|
|
16
|
+
{ readonly generate: (spec: OpenAPISpec, options: OpenApiGenerateOptions) => Effect.Effect<string> }
|
|
17
|
+
>()("OpenApiGenerator") {}
|
|
18
|
+
|
|
19
|
+
export interface OpenApiGenerateOptions {
|
|
20
|
+
/**
|
|
21
|
+
* The name to give to the generated client.
|
|
22
|
+
*/
|
|
23
|
+
readonly name: string
|
|
24
|
+
/**
|
|
25
|
+
* When `true`, will **only** generate types based on the provided OpenApi
|
|
26
|
+
* specification (without corresponding schemas).
|
|
27
|
+
*/
|
|
28
|
+
readonly typeOnly: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Hook to transform each JSON Schema node before processing.
|
|
31
|
+
*/
|
|
32
|
+
readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const methodNames: ReadonlyArray<OpenAPISpecMethodName> = [
|
|
36
|
+
"get",
|
|
37
|
+
"put",
|
|
38
|
+
"post",
|
|
39
|
+
"delete",
|
|
40
|
+
"options",
|
|
41
|
+
"head",
|
|
42
|
+
"patch",
|
|
43
|
+
"trace"
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
export const make = Effect.gen(function*() {
|
|
47
|
+
const generate = Effect.fn(
|
|
48
|
+
function*(spec: OpenAPISpec, options: OpenApiGenerateOptions) {
|
|
49
|
+
const generator = JsonSchemaGenerator.make()
|
|
50
|
+
const openApiTransformer = yield* OpenApiTransformer.OpenApiTransformer
|
|
51
|
+
|
|
52
|
+
// If we receive a Swagger 2.0 spec, convert it to an OpenApi 3.0 spec
|
|
53
|
+
if (isSwaggerSpec(spec)) {
|
|
54
|
+
spec = yield* convertSwaggerSpec(spec)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveRef(ref: string) {
|
|
58
|
+
const parts = ref.split("/").slice(1)
|
|
59
|
+
let current: any = spec
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
current = current[part]
|
|
62
|
+
}
|
|
63
|
+
return current
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const operations: Array<ParsedOperation.ParsedOperation> = []
|
|
67
|
+
|
|
68
|
+
function handlePath(path: string, methods: OpenAPISpecPathItem): void {
|
|
69
|
+
for (const method of methodNames) {
|
|
70
|
+
const operation = methods[method]
|
|
71
|
+
|
|
72
|
+
if (Predicate.isUndefined(operation)) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const id = operation.operationId
|
|
77
|
+
? Utils.camelize(operation.operationId)
|
|
78
|
+
: `${method.toUpperCase()}${path}`
|
|
79
|
+
|
|
80
|
+
const description = Utils.nonEmptyString(operation.description) ?? Utils.nonEmptyString(operation.summary)
|
|
81
|
+
|
|
82
|
+
const { pathIds, pathTemplate } = processPath(path)
|
|
83
|
+
|
|
84
|
+
const op = ParsedOperation.makeDeepMutable({
|
|
85
|
+
id,
|
|
86
|
+
method,
|
|
87
|
+
description,
|
|
88
|
+
pathIds,
|
|
89
|
+
pathTemplate
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const schemaId = Utils.identifier(operation.operationId ?? path)
|
|
93
|
+
|
|
94
|
+
const validParameters = operation.parameters?.filter((param) => {
|
|
95
|
+
return param.in !== "path" && param.in !== "cookie"
|
|
96
|
+
}) ?? []
|
|
97
|
+
|
|
98
|
+
if (validParameters.length > 0) {
|
|
99
|
+
const schema = {
|
|
100
|
+
type: "object" as JsonSchema.Type,
|
|
101
|
+
properties: {} as Record<string, any>,
|
|
102
|
+
required: [] as Array<string>,
|
|
103
|
+
additionalProperties: false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (let parameter of validParameters) {
|
|
107
|
+
if ("$ref" in parameter) {
|
|
108
|
+
parameter = resolveRef(parameter.$ref as string)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (parameter.in === "path") {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const paramSchema = parameter.schema
|
|
116
|
+
const added: Array<string> = []
|
|
117
|
+
if ("properties" in paramSchema && Predicate.isObject(paramSchema.properties)) {
|
|
118
|
+
const required = "required" in paramSchema
|
|
119
|
+
? paramSchema.required as Array<string>
|
|
120
|
+
: []
|
|
121
|
+
|
|
122
|
+
for (const [name, propSchema] of Object.entries(paramSchema.properties)) {
|
|
123
|
+
const adjustedName = `${parameter.name}[${name}]`
|
|
124
|
+
schema.properties[adjustedName] = propSchema
|
|
125
|
+
if (required.includes(name)) {
|
|
126
|
+
schema.required.push(adjustedName)
|
|
127
|
+
}
|
|
128
|
+
added.push(adjustedName)
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
schema.properties[parameter.name] = parameter.schema
|
|
132
|
+
if (parameter.required) {
|
|
133
|
+
schema.required.push(parameter.name)
|
|
134
|
+
}
|
|
135
|
+
added.push(parameter.name)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (parameter.in === "query") {
|
|
139
|
+
Utils.spreadElementsInto(added, op.urlParams)
|
|
140
|
+
} else if (parameter.in === "header") {
|
|
141
|
+
Utils.spreadElementsInto(added, op.headers)
|
|
142
|
+
} else if (parameter.in === "cookie") {
|
|
143
|
+
Utils.spreadElementsInto(added, op.cookies)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
op.params = generator.addSchema(
|
|
148
|
+
`${schemaId}Params`,
|
|
149
|
+
schema
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
op.paramsOptional = !schema.required || schema.required.length === 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (Predicate.isNotUndefined(operation.requestBody?.content?.["application/json"]?.schema)) {
|
|
156
|
+
op.payload = generator.addSchema(
|
|
157
|
+
`${schemaId}RequestJson`,
|
|
158
|
+
operation.requestBody.content["application/json"].schema
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (Predicate.isNotUndefined(operation.requestBody?.content?.["multipart/form-data"]?.schema)) {
|
|
163
|
+
op.payload = generator.addSchema(
|
|
164
|
+
`${schemaId}RequestFormData`,
|
|
165
|
+
operation.requestBody.content["multipart/form-data"].schema
|
|
166
|
+
)
|
|
167
|
+
op.payloadFormData = true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let defaultSchema: string | undefined
|
|
171
|
+
for (const entry of Object.entries(operation.responses ?? {})) {
|
|
172
|
+
const status = entry[0]
|
|
173
|
+
let response = entry[1]
|
|
174
|
+
|
|
175
|
+
while ("$ref" in response) {
|
|
176
|
+
response = resolveRef(response.$ref as string)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Predicate.isNotUndefined(response.content?.["application/json"]?.schema)) {
|
|
180
|
+
const schemaName = generator.addSchema(
|
|
181
|
+
`${schemaId}${status}`,
|
|
182
|
+
response.content["application/json"].schema
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if (status === "default") {
|
|
186
|
+
defaultSchema = schemaName
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const statusLower = status.toLowerCase()
|
|
191
|
+
const statusMajorNumber = Number(status[0])
|
|
192
|
+
if (Number.isNaN(statusMajorNumber)) {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (statusMajorNumber < 4) {
|
|
196
|
+
op.successSchemas.set(statusLower, schemaName)
|
|
197
|
+
} else {
|
|
198
|
+
op.errorSchemas.set(statusLower, schemaName)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Handle SSE streaming responses (text/event-stream)
|
|
203
|
+
if (
|
|
204
|
+
Predicate.isUndefined(op.sseSchema) &&
|
|
205
|
+
Predicate.isNotUndefined(response.content?.["text/event-stream"]?.schema)
|
|
206
|
+
) {
|
|
207
|
+
const statusMajorNumber = Number(status[0])
|
|
208
|
+
if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
|
|
209
|
+
op.sseSchema = generator.addSchema(
|
|
210
|
+
`${schemaId}${status}Sse`,
|
|
211
|
+
response.content["text/event-stream"].schema
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Handle binary streaming responses (application/octet-stream)
|
|
217
|
+
if (Predicate.isNotUndefined(response.content?.["application/octet-stream"])) {
|
|
218
|
+
const statusMajorNumber = Number(status[0])
|
|
219
|
+
if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
|
|
220
|
+
op.binaryResponse = true
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (Predicate.isUndefined(response.content)) {
|
|
225
|
+
if (status !== "default") {
|
|
226
|
+
op.voidSchemas.add(status.toLowerCase())
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (op.successSchemas.size === 0 && Predicate.isNotUndefined(defaultSchema)) {
|
|
232
|
+
op.successSchemas.set("2xx", defaultSchema)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
operations.push(op)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
240
|
+
handlePath(path, methods)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// TODO: make a CLI option ?
|
|
244
|
+
const importName = "Schema"
|
|
245
|
+
const source = getDialect(spec)
|
|
246
|
+
const generation = generator.generate(source, spec.components?.schemas ?? {}, options.typeOnly, {
|
|
247
|
+
onEnter: options.onEnter
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
return String.stripMargin(
|
|
251
|
+
`|${openApiTransformer.imports(importName, operations)}
|
|
252
|
+
|${generation}
|
|
253
|
+
|${openApiTransformer.toImplementation(importName, options.name, operations)}
|
|
254
|
+
|
|
|
255
|
+
|${openApiTransformer.toTypes(importName, options.name, operations)}`
|
|
256
|
+
)
|
|
257
|
+
},
|
|
258
|
+
(effect, _, options) =>
|
|
259
|
+
Effect.provideServiceEffect(
|
|
260
|
+
effect,
|
|
261
|
+
OpenApiTransformer.OpenApiTransformer,
|
|
262
|
+
options.typeOnly
|
|
263
|
+
? Effect.sync(OpenApiTransformer.makeTransformerTs)
|
|
264
|
+
: Effect.sync(OpenApiTransformer.makeTransformerSchema)
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return { generate } as const
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
function getDialect(spec: OpenAPISpec): "openapi-3.0" | "openapi-3.1" {
|
|
272
|
+
return spec.openapi.trim().startsWith("3.0") ? "openapi-3.0" : "openapi-3.1"
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export const layerTransformerSchema: Layer.Layer<OpenApiGenerator> = Layer.effect(OpenApiGenerator, make)
|
|
276
|
+
|
|
277
|
+
export const layerTransformerTs: Layer.Layer<OpenApiGenerator> = Layer.effect(OpenApiGenerator, make)
|
|
278
|
+
|
|
279
|
+
const isSwaggerSpec = (spec: OpenAPISpec) => "swagger" in spec
|
|
280
|
+
|
|
281
|
+
const convertSwaggerSpec = Effect.fn((spec: OpenAPISpec) =>
|
|
282
|
+
Effect.callback<OpenAPISpec>((resume) => {
|
|
283
|
+
SwaggerToOpenApi.convertObj(
|
|
284
|
+
spec as any,
|
|
285
|
+
{ laxDefaults: true, laxurls: true, patch: true, warnOnly: true },
|
|
286
|
+
(err, result) => {
|
|
287
|
+
if (err) {
|
|
288
|
+
resume(Effect.die(err))
|
|
289
|
+
} else {
|
|
290
|
+
resume(Effect.succeed(result.openapi as any))
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
}).pipe(Effect.withSpan("OpenApi.convertSwaggerSpec"))
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const processPath = (path: string): {
|
|
298
|
+
readonly pathIds: Array<string>
|
|
299
|
+
readonly pathTemplate: string
|
|
300
|
+
} => {
|
|
301
|
+
const pathIds: Array<string> = []
|
|
302
|
+
path = path.replace(/{([^}]+)}/g, (_, name) => {
|
|
303
|
+
const id = Utils.camelize(name)
|
|
304
|
+
pathIds.push(id)
|
|
305
|
+
return "${" + id + "}"
|
|
306
|
+
})
|
|
307
|
+
const pathTemplate = "`" + path + "`"
|
|
308
|
+
return { pathIds, pathTemplate } as const
|
|
309
|
+
}
|