Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"test": "recommended"
},
"rules": {
"recommended": true,
"preset": "recommended",
"complexity": {
"noForEach": "error"
},
Expand Down
8 changes: 8 additions & 0 deletions integration-tests-definitions/json-schemas/address.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
},
"required": ["street", "city"]
}
9 changes: 9 additions & 0 deletions integration-tests-definitions/json-schemas/user.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"name": { "type": "string" },
"address": { "$ref": "./address.json" },
"self": { "$ref": "./user.json" }
},
"required": ["name"]
}
23 changes: 23 additions & 0 deletions integration-tests/typescript-json-schema/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@integration/typescript-json-schema",
"version": "0.0.1",
"main": "./dist/index.js",
"author": "Michael Nahkies",
"license": "MIT",
"private": true,
"scripts": {
"clean": "rm -rf ./dist && rm -rf ./src/generated",
"validate": "tsc -p ./tsconfig.json"
},
"type": "module",
"dependencies": {
"@nahkies/typescript-fetch-runtime": "workspace:*",
"dotenv": "^17.4.2",
"tslib": "^2.8.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.20.0",
"typescript": "catalog:"
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions integration-tests/typescript-json-schema/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"outDir": "./dist",
"rootDir": "./src",
/* exercise the code path where exactOptionalPropertyTypes is disabled */
"exactOptionalPropertyTypes": false,
"noUnusedLocals": true,
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export const loadRuntimeTypes = async (
| "typescript-fetch"
| "typescript-axios"
| "typescript-koa"
| "typescript-express",
| "typescript-express"
| "typescript-json-schema",
) => {
const fileRootPath = "file:///"

Expand Down Expand Up @@ -119,6 +120,7 @@ export const loadRuntimeTypes = async (
path: "/node_modules/@nahkies/typescript-express-runtime/joi.d.mts",
},
],
"typescript-json-schema": [],
}

for (const file of files[template]) {
Expand Down
2 changes: 1 addition & 1 deletion packages/openapi-code-generator/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const program = new Command()
.addOption(
new Option("--input-type <value>", "type of input specification")
.env("OPENAPI_INPUT_TYPE")
.choices(["openapi3", "typespec"] as const)
.choices(["openapi3", "typespec", "json-schema"] as const)
.default("openapi3" as const)
.makeOptionMandatory(),
)
Expand Down
5 changes: 3 additions & 2 deletions packages/openapi-code-generator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {ServerImplementationMethod} from "./templates.types.ts"

export type Config = {
input: string
inputType: "openapi3" | "typespec"
inputType: "openapi3" | "typespec" | "json-schema"
overrideSpecificationTitle?: string | undefined
output: string
template:
Expand All @@ -17,6 +17,7 @@ export type Config = {
| "typescript-angular"
| "typescript-koa"
| "typescript-express"
| "typescript-json-schema"
schemaBuilder: "zod-v3" | "zod-v4" | "joi"
enableRuntimeResponseValidation: boolean
enableTypedBasePaths: boolean
Expand Down Expand Up @@ -46,7 +47,7 @@ const tsServerImplementationSchema = z.enum([

export const configSchema = z.object({
input: z.string(),
inputType: z.enum(["openapi3", "typespec"]),
inputType: z.enum(["openapi3", "typespec", "json-schema"]),
overrideSpecificationTitle: z.string().optional(),
output: z.string(),
template: templatesSchema,
Expand Down
8 changes: 7 additions & 1 deletion packages/openapi-code-generator/src/core/dependency-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,13 @@ export function buildDependencyGraph(
const order: string[] = []

// TODO: this may miss extracted in-line schemas
for (const [name, schema] of Object.entries(schemaProvider.allSchemas())) {
for (const [name, schema] of Object.entries(
schemaProvider.allSchemas(),
).concat(
schemaProvider
.allJsonSchemaDocuments()
.map((it) => [it.filename, it.schema]),
)) {
remaining.set(
getNameForRef({$ref: name}),
getDependenciesFromSchema(schema, getNameForRef),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export interface IFsAdaptor {

existsSync(path: string): boolean

isDir(path: string): Promise<boolean>

readDir(path: string): Promise<string[]>

mkDir(path: string, recursive: boolean): Promise<void>

resolve(request: string, fromDir: string): string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export class NodeFsAdaptor implements IFsAdaptor {

async exists(path: string) {
try {
const stat = await fs.stat(path)
return stat.isFile()
await fs.stat(path)
return true
} catch (err) {
if (
typeof err === "object" &&
Expand All @@ -33,6 +33,27 @@ export class NodeFsAdaptor implements IFsAdaptor {
return existsSync(path)
}

async isDir(path: string) {
try {
const stat = await fs.stat(path)
return stat.isDirectory()
} catch (err) {
if (
typeof err === "object" &&
err !== null &&
Reflect.get(err, "code") === "ENOENT"
) {
return false
}
throw err
}
}

async readDir(path: string) {
const files = await fs.readdir(path, {recursive: true})
return files.map((file) => pathModule.join(path, file))
}

async mkDir(path: string, recursive = true) {
await fs.mkdir(path, {recursive})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ export class WebFsAdaptor implements IFsAdaptor {
return this.files.has(path)
}

async isDir(path: string) {
const prefix = path.endsWith("/") ? path : `${path}/`
return Array.from(this.files.keys()).some((it) => it.startsWith(prefix))
}

async readDir(path: string) {
const prefix = path.endsWith("/") ? path : `${path}/`
return Array.from(
new Set(
Array.from(this.files.keys())
.filter((it) => it.startsWith(prefix))
.map((it) => it.slice(prefix.length).split("/")[0])
.filter((it): it is string => !!it),
),
)
}

async mkDir() {
/*noop*/
}
Expand Down
20 changes: 17 additions & 3 deletions packages/openapi-code-generator/src/core/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type InputConfig = {
export interface ISchemaProvider {
schema(maybeRef: MaybeIRModel | Reference): IRModel
allSchemas(): Record<string, MaybeIRModel>
allJsonSchemaDocuments(): {filename: string; schema: IRModel}[]
preprocess(maybePreprocess: Reference | xInternalPreproccess): IRPreprocess
}

Expand All @@ -65,10 +66,23 @@ export class Input implements ISchemaProvider {
return this.normalizeServers(coalesce(this.loader.entryPoint.servers, []))
}

allSchemas(): Record<string, IRModel> {
const allDocuments = this.loader.allDocuments()
entryPoint() {
return this.loader.entryPoint
}

allDocuments() {
return this.loader.allDocuments()
}

allJsonSchemaDocuments(): {filename: string; schema: IRModel}[] {
return this.loader.allJsonSchemas().map((it) => ({
filename: it.filename,
schema: this.schemaNormalizer.normalize(it.schema),
}))
}

const schemas = allDocuments.reduce(
allSchemas(documents = this.allDocuments()): Record<string, IRModel> {
const schemas = documents.reduce(
(acc, it) => {
return Object.assign(acc, it.components?.schemas ?? {})
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function headersForRemoteUri(

export class GenericLoader {
constructor(
private readonly fsAdaptor: IFsAdaptor,
readonly fsAdaptor: IFsAdaptor,
private readonly requestHeaders: GenericLoaderRequestHeaders = {},
) {}

Expand Down
59 changes: 57 additions & 2 deletions packages/openapi-code-generator/src/core/loaders/openapi-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
export class OpenapiLoader {
private readonly virtualLibrary = new Map<string, VirtualDefinition>()
private readonly library = new Map<string, OpenapiDocument>()
private readonly jsonSchemas = new Map<string, Schema>()

private constructor(
private readonly entryPointKey: string,
Expand Down Expand Up @@ -56,6 +57,13 @@
return Array.from(this.library.values())
}

allJsonSchemas(): {filename: string; schema: Schema}[] {
return Array.from(this.jsonSchemas.entries()).map(([key, value]) => ({
filename: key,
schema: value,
}))
}

paths(maybeRef: Reference | Path): Path {
return isRef(maybeRef) ? this.$ref(maybeRef) : maybeRef
}
Expand Down Expand Up @@ -88,7 +96,7 @@
const [key, objPath] = $ref.split("#")

// biome-ignore lint/suspicious/noExplicitAny: dynamic
const obj: any = key && this.library.get(key)
const obj: any = key && (this.library.get(key) ?? this.jsonSchemas.get(key))

if (!obj) {
throw new Error(`could not load $ref, key not loaded. $ref: '${$ref}'`)
Expand Down Expand Up @@ -148,6 +156,15 @@
}
}

private async loadJsonSchema(file: string) {
if (this.jsonSchemas.has(file)) {
return
}
const [loadedFrom, definition] = await this.genericLoader.loadFile(file)
this.jsonSchemas.set(loadedFrom, definition as any)

Check warning on line 164 in packages/openapi-code-generator/src/core/loaders/openapi-loader.ts

View workflow job for this annotation

GitHub Actions / build (^22)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.

Check warning on line 164 in packages/openapi-code-generator/src/core/loaders/openapi-loader.ts

View workflow job for this annotation

GitHub Actions / build (^24)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.

Check warning on line 164 in packages/openapi-code-generator/src/core/loaders/openapi-loader.ts

View workflow job for this annotation

GitHub Actions / build (^26)

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
await this.normalizeRefs(loadedFrom, definition)
}

// biome-ignore lint/suspicious/noExplicitAny: dynamic
private async normalizeRefs(loadedFrom: string, obj: any) {
for (const key in obj) {
Expand Down Expand Up @@ -194,7 +211,7 @@
static async create(
config: {
entryPoint: string
fileType: "openapi3" | "typespec"
fileType: "openapi3" | "typespec" | "json-schema"
titleOverride: string | undefined
},
validator: IOpenapiValidator,
Expand Down Expand Up @@ -224,6 +241,44 @@
await loader.loadFileContent(entryPoint, openapi)
break
}
case "json-schema": {
const isDir =
!isRemote(entryPoint) &&
(await genericLoader.fsAdaptor.isDir(entryPoint))

const files = isDir
? (await genericLoader.fsAdaptor.readDir(entryPoint)).filter(
(it) =>
it.endsWith(".json") ||
it.endsWith(".yaml") ||
it.endsWith(".yml"),
)
: [entryPoint]

for (const file of files) {
await loader.loadJsonSchema(file)
}
//
// const openapi: OpenapiDocument = {
// openapi: "3.0.3",
// info: {
// title: config.titleOverride ?? path.basename(entryPoint),
// version: "1.0.0",
// },
// paths: {},
// components: {
// schemas: Object.fromEntries(
// files.map((file) => {
// const name = path.parse(file).name
// return [name, {$ref: `${path.join(entryPoint, file)}#`}]
// }),
// ),
// },
// }
// await loader.loadFileContent(entryPoint, openapi)

break
}
default: {
throw new Error(`unsupported file type '${config.fileType}'`)
}
Expand Down
Loading
Loading