Skip to content

feat: handle inline schema in schemas plugin#3663

Open
malcolm-kee wants to merge 1 commit intohey-api:mainfrom
malcolm-kee:fix/inline-schema
Open

feat: handle inline schema in schemas plugin#3663
malcolm-kee wants to merge 1 commit intohey-api:mainfrom
malcolm-kee:fix/inline-schema

Conversation

@malcolm-kee
Copy link
Copy Markdown
Contributor

This is second attempt to fix the issue

I previously just let coding agent do it without having time to validate it, so I scraped that.

Summary

  • The @hey-api/schemas plugin previously only generated schemas from #/components/schemas (or definitions in Swagger 2.0). Parameters defined inline on path items or operations (common in FastAPI-generated specs) were silently ignored.
  • This PR walks paths and collects inline parameters (query, header, path, cookie), groups them by location, and emits a per-operation schema object like GetCatsQuerySchema or GetCatsByCatIdPathSchema.

Caution

The parameter-to-schema conversion logic lives directly in the plugin. Each spec-version function has its own copy of the iteration and assembly logic.

There might be a better place for this parameter parsing. Open to feedback on whether this duplication is acceptable or should be refactored.

Naming convention

Each inline parameter group is exported as {OperationId}{LocationSuffix}Schema, where:

  • OperationId comes from the spec's operationId (or is auto-generated from path + method by the existing IR layer, e.g. GET /cats/{cat_id} becomes GetCatsByCatId). The IR already deduplicates IDs by appending a counter on collision.
  • LocationSuffix is one of Query, Headers, Path, Cookies.

Examples: GetCatsQuerySchema, GetCatsByCatIdHeadersSchema, GetCatsByCatIdPathSchema.

How other plugins handle parameters

Other plugins don't face the same naming decision because they don't produce separate top-level exports per parameter location:

Plugin Strategy Example
@hey-api/types Single {OperationId}Data type with query, path, headers, body as nested properties CallWithParametersData { headers: { ... }; path: { ... }; query: { ... }; }
Fastify Derives from the types plugin's nested keys, mapping to Fastify-specific names: Querystring, Params, Headers, Body type.prop('Querystring', ...) referencing Data['query']
NestJS Also references the types plugin's nested keys using path, query, headers, body directly -
@hey-api/sdk Flattens all parameters; only prefixes with {location}_ when names conflict across locations path_foo, query_foo

The types plugin avoids the problem entirely by nesting everything under one name. Fastify and NestJS just reference those nested types. None of them emit standalone per-location exports.

Caution

Conflict risk: It's possible for a component schema name to collide with a generated parameter schema name -- e.g. a component named GetCatsQuery would produce GetCatsQuerySchema, same as the inline query params for GET /cats. This seems unlikely in practice but there's no deduplication handling for it currently.

@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 29, 2026

⚠️ No Changeset found

Latest commit: fd27ed5

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 29, 2026

@malcolm-kee is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@dosubot dosubot bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Mar 29, 2026
@pullfrog
Copy link
Copy Markdown

pullfrog bot commented Mar 29, 2026

Reviewed PR #3663. Submitted feedback on 4 issues: unconditional generation of parameter schemas for all operations (breaking change for existing users), collectionFormat leaking into V2 JSON Schema output, required array including params without properties entries, and code triplication across spec versions.

Task list (5/5 completed)
  • Read the diff: core plugin changes (plugin.ts)
  • Read the diff: test specs and snapshots
  • Read existing plugin.ts for full context
  • Trace data flow and verify correctness
  • Draft and submit review
Pullfrog  | [View workflow run](https://github.com/hey-api/openapi-ts/actions/runs/23699758636/job/69041187700) | Triggered by [Pullfrog](https://pullfrog.com) | [𝕏](https://x.com/pullfrogai)

@dosubot dosubot bot added the feature 🚀 Feature request. label Mar 29, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 29, 2026

Codecov Report

❌ Patch coverage is 1.43885% with 137 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.73%. Comparing base (51d56b3) to head (fd27ed5).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
.../openapi-ts/src/plugins/@hey-api/schemas/plugin.ts 1.43% 98 Missing and 39 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3663      +/-   ##
==========================================
- Coverage   38.99%   38.73%   -0.26%     
==========================================
  Files         515      515              
  Lines       18901    19030     +129     
  Branches     5591     5636      +45     
==========================================
+ Hits         7370     7372       +2     
- Misses       9326     9417      +91     
- Partials     2205     2241      +36     
Flag Coverage Δ
unittests 38.73% <1.43%> (-0.26%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium urgency. The feature works for the targeted inline-parameter use case, but it also unconditionally generates parameter schemas for every operation in every spec (visible in the ~1400 new lines across the three default snapshots). This is a breaking change for existing users whose build pipelines, barrel exports, or bundle sizes depend on the current output. Two smaller correctness issues below.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run𝕏

Comment on lines +379 to +471
for (const path in context.spec.paths) {
if (path.startsWith('x-')) {
continue;
}

const pathItem = context.spec.paths[path as `/${string}`]!;

for (const method of httpMethods) {
const operation = pathItem[method as keyof typeof pathItem] as
| OpenAPIV2.OperationObject
| undefined;
if (!operation) {
continue;
}

const irOperation = context.ir.paths?.[path as `/${string}`]?.[method];
if (!irOperation) {
continue;
}

for (const { location, suffix } of paramLocations) {
const params = new Map<string, OpenAPIV2.ParameterObject>();

for (const param of pathItem.parameters ?? []) {
if ('$ref' in param || param.in !== location) {
continue;
}
params.set(param.name, param as OpenAPIV2.ParameterObject);
}

for (const param of operation.parameters ?? []) {
if ('$ref' in param || param.in !== location) {
continue;
}
params.set(param.name, param as OpenAPIV2.ParameterObject);
}

if (params.size === 0) {
continue;
}

const properties: Record<string, OpenAPIV2.SchemaObject> = {};
const required: Array<string> = [];
for (const [paramName, param] of params) {
if (!('type' in param)) {
continue;
}
const propSchema: Record<string, unknown> = {};
for (const [key, value] of Object.entries(param)) {
if (key === 'allowEmptyValue' || key === 'in' || key === 'name' || key === 'required') {
continue;
}
propSchema[key] = value;
}
properties[paramName] = propSchema as OpenAPIV2.SchemaObject;
if (param.required) {
required.push(paramName);
}
}

const locationSchema = {
properties,
...(required.length && { required }),
type: 'object',
} as OpenAPIV2.SchemaObject;

const name = `${toCase(irOperation.id, 'PascalCase')}${suffix}`;
const symbol = plugin.symbol(schemaName({ name, plugin, schema: locationSchema }), {
meta: {
category: 'schema',
resource: 'operation',
resourceId: irOperation.id,
tool: 'json-schema',
},
});
const obj = schemaToJsonSchemaDraft_04({
context,
plugin,
schema: locationSchema,
});
const statement = $.const(symbol)
.export()
.assign(
$(
$.fromValue(obj, {
layout: 'pretty',
}),
).as('const'),
);
plugin.node(statement);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop generates parameter schemas for all operations — not just specs whose parameters lack component definitions. The default test spec already defines its parameters inline (most specs do), so the 2.0.x default snapshot grows by ~390 lines, 3.0.x by ~630, and 3.1.x by ~700.

Every existing user of @hey-api/schemas will see new exports appear the next time they regenerate. Depending on how they consume the output (barrel re-exports, bundle analysis, snapshot tests), this could be disruptive.

Consider gating this behind an opt-in config option (e.g. inlineParameters: true) so the behavior is additive without surprising existing users. Alternatively, if the intent is for this to be on by default, the PR should be framed as a breaking change with a migration note.

Comment on lines +426 to +431
const propSchema: Record<string, unknown> = {};
for (const [key, value] of Object.entries(param)) {
if (key === 'allowEmptyValue' || key === 'in' || key === 'name' || key === 'required') {
continue;
}
propSchema[key] = value;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This copies every property on the Swagger 2.0 ParameterObject except allowEmptyValue, in, name, and required — but ParameterObject has several fields that are not valid JSON Schema properties. Notably collectionFormat leaks through (visible in the CollectionFormatQuerySchema snapshot where each property includes e.g. collectionFormat: 'csv'). Other Swagger-only fields like allowEmptyValue are excluded, but collectionFormat is not.

You should either switch to an allowlist of known schema-relevant keys (type, format, items, default, enum, description, minimum, maximum, etc.) or add collectionFormat to the exclusion list.

Suggested change
const propSchema: Record<string, unknown> = {};
for (const [key, value] of Object.entries(param)) {
if (key === 'allowEmptyValue' || key === 'in' || key === 'name' || key === 'required') {
continue;
}
propSchema[key] = value;
if (!('type' in param)) {
continue;
}
const propSchema: Record<string, unknown> = {};
for (const [key, value] of Object.entries(param)) {
if (key === 'allowEmptyValue' || key === 'collectionFormat' || key === 'in' || key === 'name' || key === 'required') {
continue;
}
propSchema[key] = value;
}

Comment on lines +564 to +569
if (propSchema) {
properties[paramName] = propSchema;
}
if (param.required) {
required.push(paramName);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required.push(paramName) runs even when propSchema is undefined (no schema or content on the parameter). This means a parameter with required: true but no type information ends up in the required array without a corresponding entry in properties — producing an invalid JSON Schema.

The same issue exists in the V3.1 block at line 700.

Suggested change
if (propSchema) {
properties[paramName] = propSchema;
}
if (param.required) {
required.push(paramName);
}
if (propSchema) {
properties[paramName] = propSchema;
}
if (propSchema && param.required) {
required.push(paramName);
}

Comment on lines +641 to +737
for (const path in context.spec.paths) {
if (path.startsWith('x-')) {
continue;
}

const pathItem = context.spec.paths![path as `/${string}`]!;

for (const method of httpMethods) {
const operation = pathItem[method as keyof typeof pathItem] as
| OpenAPIV3_1.OperationObject
| undefined;
if (!operation) {
continue;
}

const irOperation = context.ir.paths?.[path as `/${string}`]?.[method];
if (!irOperation) {
continue;
}

for (const { location, suffix } of paramLocations) {
const params = new Map<string, OpenAPIV3_1.ParameterObject>();

for (const param of pathItem.parameters ?? []) {
if ('$ref' in param || param.in !== location) {
continue;
}
params.set(param.name, param);
}

for (const param of operation.parameters ?? []) {
if ('$ref' in param || param.in !== location) {
continue;
}
params.set(param.name, param);
}

if (params.size === 0) {
continue;
}

const properties: Record<string, OpenAPIV3_1.SchemaObject> = {};
const required: Array<string> = [];
for (const [paramName, param] of params) {
let propSchema: OpenAPIV3_1.SchemaObject | undefined;
if (param.schema) {
propSchema = { ...param.schema };
if (param.description) {
propSchema.description = param.description;
}
} else if (param.content) {
const mediaType = Object.values(param.content)[0];
if (mediaType?.schema) {
propSchema = { ...mediaType.schema };
}
}
if (propSchema) {
properties[paramName] = propSchema;
}
if (param.required) {
required.push(paramName);
}
}

const locationSchema = {
properties,
...(required.length && { required }),
type: 'object',
} as OpenAPIV3_1.SchemaObject;

const name = `${toCase(irOperation.id, 'PascalCase')}${suffix}`;
const symbol = plugin.symbol(schemaName({ name, plugin, schema: locationSchema }), {
meta: {
category: 'schema',
resource: 'operation',
resourceId: irOperation.id,
tool: 'json-schema',
},
});
const obj = schemaToJsonSchema2020_12({
context,
plugin,
schema: locationSchema,
});
const statement = $.const(symbol)
.export()
.assign(
$(
$.fromValue(obj, {
layout: 'pretty',
}),
).as('const'),
);
plugin.node(statement);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ~95 lines that are structurally identical to the V3.0 block (lines 508–604), differing only in type annotations (OpenAPIV3_1.* vs OpenAPIV3.*) and the schema conversion function called (schemaToJsonSchema2020_12 vs schemaToJsonSchemaDraft_05). The V2 block adds a third copy with slightly different extraction logic.

As you noted in the PR description, this duplication is a concern. One concrete approach: extract a generic collectInlineParameterSchemas helper parameterized by spec types and the schema-conversion function, then call it from each version-specific function. This would also make it easier to add the collectionFormat fix and the required guard in a single place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 🚀 Feature request. size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant