Compare commits

...
3 changed files with 65 additions and 1 deletions

No files matched your search

+1
View File
@@ -46,3 +46,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v3.5.0-beta` | Relation field |
| `v3.5.1-beta` | Relation picker fix |
| `v3.5.2-beta` | LLM documentation and MCP server |
| `v3.5.3-beta` | OpenAPI response schemas |
+13
View File
@@ -0,0 +1,13 @@
This release fixes the OpenAPI description served at `/openapi.json`, whose response schemas were empty.
## Improvements and fixes
- Every successful response in `/openapi.json` now has a schema (object properties, required keys, array items and scalar types) instead of an empty one, so client generators produce typed models. The schemas are inferred from the sample responses of the API reference.
## Upgrading
- Replace the binary, or pull the new image. No schema migration and no new environment variable.
## Known limitations
- Because the schemas come from sample responses, a field that is `null` in a sample is typed `null`, a field missing from a sample is missing from its schema, and timestamps are plain strings.
+51 -1
View File
@@ -212,6 +212,53 @@ function parseExample(response: string) {
}
}
type Schema = Record<string, unknown>
function mergeSchemas(schemas: Schema[]): Schema {
const unique = [...new Map(schemas.map((schema) => [JSON.stringify(schema), schema])).values()]
if (unique.length === 1) return unique[0]
const objects = unique.filter((schema) => schema.type === 'object')
if (objects.length > 1 && objects.length === unique.length) {
const keys = [...new Set(objects.flatMap((schema) => Object.keys(schema.properties as Schema)))]
return {
type: 'object',
properties: Object.fromEntries(
keys.map((key) => [
key,
mergeSchemas(
objects
.map((schema) => (schema.properties as Record<string, Schema>)[key])
.filter((schema) => schema !== undefined),
),
]),
),
required: keys.filter((key) =>
objects.every((schema) => key in (schema.properties as Schema)),
),
}
}
return { anyOf: unique }
}
function inferSchema(value: unknown): Schema {
if (value === null) return { type: 'null' }
if (Array.isArray(value)) {
return value.length === 0
? { type: 'array', items: {} }
: { type: 'array', items: mergeSchemas(value.map(inferSchema)) }
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
return {
type: 'object',
properties: Object.fromEntries(entries.map(([key, child]) => [key, inferSchema(child)])),
required: entries.map(([key]) => key),
}
}
if (typeof value === 'number') return { type: Number.isInteger(value) ? 'integer' : 'number' }
return { type: typeof value }
}
function operation(item: ApiEndpoint) {
const pathParameters = [...item.path.matchAll(/\{(\w+)\}/g)].map((match) => ({
name: match[1],
@@ -234,7 +281,10 @@ function operation(item: ApiEndpoint) {
: {
description: 'Success',
content: {
'application/json': { schema: {}, ...(example === undefined ? {} : { example }) },
'application/json': {
schema: example === undefined ? {} : inferSchema(example),
...(example === undefined ? {} : { example }),
},
},
},
}