Skip to content

Commit 00e668a

Browse files
feat(core): add splitOperationsByContentType option to divide operati… (#23935)
* feat(core): add splitOperationsByContentType option to divide operations by content-type (#6708) OpenAPI lets a single operation expose several request/response content-types with different schemas, but generators keep only the first one ("Multiple schemas found in the OAS 'content' section, returning only the first"). This yields a single, mistyped method and makes the other content-types unusable. Add an opt-in option `splitOperationsByContentType` (default false) that divides such an operation into one operation per content-type — the cartesian product of the request-body and success-response content-types (deduplicated by schema). The division happens at the spec level in DefaultCodegen#preprocessOpenAPI. Each variant is a narrowed swagger Operation (single content-type on each axis) with a typed, collision-free operationId (request -> "With<Subtype>", response -> "As<Subtype>", e.g. createReportWithXmlAsPdf). Variants are stored on the original operation under the "x-content-type-variants" extension and expanded by DefaultGenerator#processOperation, so every variant re-enters fromOperation and is typed natively by the target generator. The feature is therefore language-neutral: no per-language type re-derivation, no template change. Tested in DefaultCodegenTest for both the request×response cartesian (4 variants) and the response-only case (2 variants), and that unambiguous operations are left untouched. * #6708 : Move the clone methods to ModelUtils to lightweight DefaultCodegen * #6708 : Add new split operations option in every providers * #6708 : Build project and update samples * #6708 : Other solution without X variant and using divide directly while processing operations * #6708 Simplification of code, removing useless "findMultiSchemaSuccessResponseCode" * #6708 : Remove default implemenation in Interface. No sense because the default implementation is in CodegenDefault * #6708 : make splitOperationsByContentType a global property, merged back per generator Following the review on #23935: the option was a CLI option repeated in every generator that wanted it. It is now a global property, read once from GlobalSettings in DefaultCodegen.processOpts, like skipFormModel already is. DefaultGenerator.processOperation asks the config to divide an operation before processing it, and DefaultCodegen implements the division once, language neutrally: an operation whose request body and/or method response expose several content-types with different schemas becomes one operation per (request, response) content-type pair, each narrowed to a single media-type and given a typed, collision-free operationId. Every variant re-enters fromOperation, so its body and return types are resolved natively by the target generator - the shared code re-derives no types of its own. Each variant also carries x-content-type-variant-* extensions recording where it sits in the matrix, so a generator able to express the whole matrix in a single construct can merge the variants back instead of emitting one method per combination. typescript-fetch does: the variants collapse into one method whose request type is a union discriminated by `contentType`, and whose return type is picked by overloads on `accept`, each branch keeping the types the split resolved for it. A generator that does not merge simply gets the separate methods, which is what a statically-typed language needs anyway. With the option off, divideOperationsByContentType returns the operation as a singleton and the merge returns immediately: regenerating the 19 typescript-fetch sample configs produces a byte-identical tree. * Fix problem with samples of petstore in resttemplate springboot4-jackson3 * #6708 : add a typescript-fetch sample generated with the option on Nothing committed showed what splitOperationsByContentType emits, and nothing in CI compiled it: the option was exercised only by unit tests asserting on strings in a temp directory. Reviewers had to build the branch to see the feature, and a regression that produced uncompilable TypeScript would have gone unnoticed - the review of this branch found four of those. The sample's spec gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body whose operation is split on the response axis only, a request split mixing JSON and multipart, and an enum parameter carried by a split operation. bin/ts-typecheck-all.sh discovers samples on its own - any generated directory holding both a tsconfig.json and a package.json is typechecked - so setting npmName is all it takes for CI to compile this one. No workflow change needed. Writing the sample immediately paid for itself: the form body assembled inside the content-type switch was under-indented by eight columns. IndentedLambda leaves the first line alone, and moving the partial to column zero in the previous commit removed the literal spaces that used to indent it. * #6708 : answer the review of the split-by-content-type sample Three of the four points raised were about the sample, which is what the sample is for - the output is readable now. A media type is case-insensitive (RFC 9110), so a server answering `Application/PDF` fell through the dispatch chain and had its body decoded as the default content-type. Both sides of the comparison are lower-cased now. The merged operation only advertised one media type per axis: the split narrows each variant to a single one and the merge never put them back, so the generated documentation hid that createReport also accepts a patch body and can answer with a PDF. The union is restored on the merged operation. apis.mustache reads consumes only where the request axis was not split - a case where the union is the single value anyway - and never reads produces, so nothing but the documentation changes. The sample demonstrated the request axis with an `application/xml` body backed by an object schema. typescript-fetch has no XML serialiser and JSON-encodes that body under an XML Content-Type - with or without this option, as generating the same spec with the option off shows. The behaviour is not this option's doing, but advertising it in the sample promised something the generator does not deliver, so the sample now splits on `application/merge-patch+json`, which it does. The limitation is stated in docs/global-properties.md instead: the option decides which content-types get an operation, not how a body is encoded. The remaining point, body serialisation reading the pre-override header map in runtime.ts, is upstream code this branch does not touch; it applies to every typescript-fetch client and belongs in its own change.
1 parent cc71163 commit 00e668a

53 files changed

Lines changed: 3953 additions & 126 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
generatorName: typescript-fetch
2+
outputDir: samples/client/petstore/typescript-fetch/builds/split-by-content-type
3+
inputSpec: modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-sample.yaml
4+
templateDir: modules/openapi-generator/src/main/resources/typescript-fetch
5+
globalProperties:
6+
splitOperationsByContentType: "true"
7+
additionalProperties:
8+
npmVersion: 1.0.0
9+
npmName: '@openapitools/typescript-fetch-split-by-content-type'
10+
snapshot: false

docs/global-properties.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,62 @@ title: Global Properties
2121
| modelDocs | Allows the user to define if model docs will be generated. Prefer using the more robust `.openapi-generator-ignore`. | `true` or `false` |
2222
| apiTests | Allows the user to define if api tests will be generated. Prefer using the more robust `.openapi-generator-ignore`. | `true` or `false` |
2323
| modelTests | Allows the user to define if model tests will be generated. Prefer using the more robust `.openapi-generator-ignore`. | `true` or `false` |
24+
| splitOperationsByContentType | Generates one operation per request/response content-type when an operation exposes several with different schemas | `true` or `false` |
25+
26+
27+
## Note on splitOperationsByContentType
28+
29+
An operation may declare several request or response content-types backed by *different* schemas. Only the
30+
first one is normally kept, which leaves the others unreachable. With `splitOperationsByContentType=true`
31+
such an operation is generated once per content-type instead — the cartesian product of the request and
32+
response axes, deduplicated by schema — each with a typed, collision-free operation id built from the base
33+
one: `With<Subtype>` for the request axis, `As<Subtype>` for the response axis, as in
34+
`createReportWithMergePatchAsPdf`.
35+
36+
The content-type declared first on each axis is the default one, consistently with the rest of the
37+
generator. The option is opt-in and off by default, because it changes the shape of the generated API.
38+
39+
Each generated operation carries `x-content-type-variant-*` extensions recording the group it was split
40+
from, the content-type it was narrowed to on each axis and the rank of that content-type in its axis. A
41+
generator whose language can express the whole matrix in a single construct uses them to merge the variants
42+
back together while keeping each one's natively resolved types. `typescript-fetch` does exactly that: it
43+
emits one method whose request type is a union discriminated by `contentType` and whose return type is
44+
selected by overloads on `accept`.
45+
46+
```ts
47+
export type CreateReportRequest = runtime.ExclusiveUnion<
48+
| { contentType?: 'application/json'; report?: Report; }
49+
| { contentType: 'application/merge-patch+json'; reportPatch?: ReportPatch; }
50+
>;
51+
52+
async createReport(requestParameters: CreateReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Receipt>;
53+
async createReport(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Blob>;
54+
```
55+
56+
`ExclusiveUnion` makes the members mutually exclusive, by declaring on each of them the keys it does not
57+
have as `never`. Without it nothing stops a caller from handing a patch body to the JSON member and having it
58+
sent under the wrong content-type: excess property checking, which would normally reject the surplus property, treats a
59+
key present in *any* member of a union as known, so it never fires here — for an object literal no more than
60+
for a variable. What rejects most shapes is unrelated: weak type detection when every property of a member
61+
is optional, a missing required property otherwise. A member with a required parameter and an optional body
62+
has neither. The helper is emitted into `runtime.ts` only when this option is on.
63+
64+
A form or multipart content-type is merged like any other: its parameters stay individual rather than
65+
gathered in a single body, so the union member carries them as they are and the body is assembled inside
66+
that content-type's branch of the switch. `Content-Type` is set in each branch rather than once up front,
67+
because a multipart body must not set it at all — `fetch` adds it with the boundary it generates.
68+
69+
The option decides *which* content-types get their own operation; it does not change how a body is
70+
serialised. Each variant is handed to the generator's existing encoders, so a media type the generator has
71+
no encoder for is still sent the way it always was — `typescript-fetch`, for one, has no XML serialiser, and
72+
an `application/xml` body backed by an object schema is JSON-encoded under an XML `Content-Type` exactly as
73+
it is without this option. Splitting makes such a content-type reachable; teaching the generator to encode
74+
it is a separate matter.
75+
76+
One case is left split rather than merged, with a warning: every operation when `useSingleRequestParameter`
77+
is off, since the parameters are then spread over the signature and there is no request object to carry the
78+
discriminant. The separate, individually typed methods the split produced are then generated as they are,
79+
which is what a statically-typed generator emits anyway.
2480

2581

2682
## Note on Global Property declaration

modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.openapitools.codegen.model.WebhooksMap;
3434

3535
import java.io.File;
36+
import java.util.Collections;
3637
import java.util.List;
3738
import java.util.Map;
3839
import java.util.Set;
@@ -130,6 +131,24 @@ public interface CodegenConfig {
130131

131132
CodegenOperation fromOperation(String resourcePath, String httpMethod, Operation operation, List<Server> servers);
132133

134+
/**
135+
* Divides an operation into one operation per content-type when it exposes several request/response
136+
* content-types with different schemas (opt-in, see {@code splitOperationsByContentType}). Each
137+
* returned operation is self-contained and re-enters {@link #fromOperation}. When the option is off or
138+
* no division applies, the operation is returned unchanged (as a singleton). {@code DefaultCodegen}
139+
* implements the division; the default here keeps the operation whole so that an implementation not
140+
* deriving from {@code DefaultCodegen} keeps compiling and simply opts out of the feature.
141+
*
142+
* @param openAPI the OpenAPI document
143+
* @param path the resource path
144+
* @param httpMethod the HTTP method
145+
* @param operation the operation to (maybe) divide
146+
* @return the operations to generate for {@code operation} (the operation itself when not divided)
147+
*/
148+
default List<Operation> divideOperationsByContentType(OpenAPI openAPI, String path, String httpMethod, Operation operation) {
149+
return Collections.singletonList(operation);
150+
}
151+
133152
List<CodegenSecurity> fromSecurity(Map<String, SecurityScheme> schemas);
134153

135154
List<CodegenServer> fromServers(List<Server> servers);

modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,24 @@ public class CodegenConstants {
156156
public static final String PREPEND_FORM_OR_BODY_PARAMETERS = "prependFormOrBodyParameters";
157157
public static final String PREPEND_FORM_OR_BODY_PARAMETERS_DESC = "Add form or body parameters to the beginning of the parameter list.";
158158

159+
public static final String SPLIT_OPERATIONS_BY_CONTENT_TYPE = "splitOperationsByContentType";
160+
161+
/**
162+
* Extensions set on every operation produced by {@code splitOperationsByContentType}, describing where
163+
* the variant sits in the content-type matrix so that a generator can merge the variants back into a
164+
* single construct instead of emitting one method per combination.
165+
* <p>
166+
* The {@code *-index} ones carry the 0-based rank of the variant's media-type in its axis, in the order
167+
* the spec declares them, so a consumer never has to rely on the order operations happen to reach it in:
168+
* rank 0 is that axis's default content-type, and the variant ranked 0 on both axes is the one a caller
169+
* gets without asking. An axis that was not split has no media-type and ranks 0.
170+
*/
171+
public static final String X_CONTENT_TYPE_VARIANT_GROUP = "x-content-type-variant-group";
172+
public static final String X_CONTENT_TYPE_VARIANT_REQUEST = "x-content-type-variant-request";
173+
public static final String X_CONTENT_TYPE_VARIANT_RESPONSE = "x-content-type-variant-response";
174+
public static final String X_CONTENT_TYPE_VARIANT_REQUEST_INDEX = "x-content-type-variant-request-index";
175+
public static final String X_CONTENT_TYPE_VARIANT_RESPONSE_INDEX = "x-content-type-variant-response-index";
176+
159177
public static final String USE_DATETIME_OFFSET = "useDateTimeOffset";
160178
public static final String USE_DATETIME_OFFSET_DESC = "Use DateTimeOffset to model date-time properties";
161179

0 commit comments

Comments
 (0)