diff --git a/bin/configs/typescript-fetch-split-by-content-type.yaml b/bin/configs/typescript-fetch-split-by-content-type.yaml new file mode 100644 index 000000000000..e05eb6900874 --- /dev/null +++ b/bin/configs/typescript-fetch-split-by-content-type.yaml @@ -0,0 +1,10 @@ +generatorName: typescript-fetch +outputDir: samples/client/petstore/typescript-fetch/builds/split-by-content-type +inputSpec: modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-sample.yaml +templateDir: modules/openapi-generator/src/main/resources/typescript-fetch +globalProperties: + splitOperationsByContentType: "true" +additionalProperties: + npmVersion: 1.0.0 + npmName: '@openapitools/typescript-fetch-split-by-content-type' + snapshot: false diff --git a/docs/global-properties.md b/docs/global-properties.md index 445b7f21ce5a..9ba39898d33c 100644 --- a/docs/global-properties.md +++ b/docs/global-properties.md @@ -21,6 +21,62 @@ title: Global Properties | modelDocs | Allows the user to define if model docs will be generated. Prefer using the more robust `.openapi-generator-ignore`. | `true` or `false` | | apiTests | Allows the user to define if api tests will be generated. Prefer using the more robust `.openapi-generator-ignore`. | `true` or `false` | | modelTests | Allows the user to define if model tests will be generated. Prefer using the more robust `.openapi-generator-ignore`. | `true` or `false` | +| splitOperationsByContentType | Generates one operation per request/response content-type when an operation exposes several with different schemas | `true` or `false` | + + +## Note on splitOperationsByContentType + +An operation may declare several request or response content-types backed by *different* schemas. Only the +first one is normally kept, which leaves the others unreachable. With `splitOperationsByContentType=true` +such an operation is generated once per content-type instead — the cartesian product of the request and +response axes, deduplicated by schema — each with a typed, collision-free operation id built from the base +one: `With` for the request axis, `As` for the response axis, as in +`createReportWithMergePatchAsPdf`. + +The content-type declared first on each axis is the default one, consistently with the rest of the +generator. The option is opt-in and off by default, because it changes the shape of the generated API. + +Each generated operation carries `x-content-type-variant-*` extensions recording the group it was split +from, the content-type it was narrowed to on each axis and the rank of that content-type in its axis. A +generator whose language can express the whole matrix in a single construct uses them to merge the variants +back together while keeping each one's natively resolved types. `typescript-fetch` does exactly that: it +emits one method whose request type is a union discriminated by `contentType` and whose return type is +selected by overloads on `accept`. + +```ts +export type CreateReportRequest = runtime.ExclusiveUnion< + | { contentType?: 'application/json'; report?: Report; } + | { contentType: 'application/merge-patch+json'; reportPatch?: ReportPatch; } +>; + +async createReport(requestParameters: CreateReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; +async createReport(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; +``` + +`ExclusiveUnion` makes the members mutually exclusive, by declaring on each of them the keys it does not +have as `never`. Without it nothing stops a caller from handing a patch body to the JSON member and having it +sent under the wrong content-type: excess property checking, which would normally reject the surplus property, treats a +key present in *any* member of a union as known, so it never fires here — for an object literal no more than +for a variable. What rejects most shapes is unrelated: weak type detection when every property of a member +is optional, a missing required property otherwise. A member with a required parameter and an optional body +has neither. The helper is emitted into `runtime.ts` only when this option is on. + +A form or multipart content-type is merged like any other: its parameters stay individual rather than +gathered in a single body, so the union member carries them as they are and the body is assembled inside +that content-type's branch of the switch. `Content-Type` is set in each branch rather than once up front, +because a multipart body must not set it at all — `fetch` adds it with the boundary it generates. + +The option decides *which* content-types get their own operation; it does not change how a body is +serialised. Each variant is handed to the generator's existing encoders, so a media type the generator has +no encoder for is still sent the way it always was — `typescript-fetch`, for one, has no XML serialiser, and +an `application/xml` body backed by an object schema is JSON-encoded under an XML `Content-Type` exactly as +it is without this option. Splitting makes such a content-type reachable; teaching the generator to encode +it is a separate matter. + +One case is left split rather than merged, with a warning: every operation when `useSingleRequestParameter` +is off, since the parameters are then spread over the signature and there is no request object to carry the +discriminant. The separate, individually typed methods the split produced are then generated as they are, +which is what a statically-typed generator emits anyway. ## Note on Global Property declaration diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java index bc775c432d7f..625ce72f3485 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java @@ -33,6 +33,7 @@ import org.openapitools.codegen.model.WebhooksMap; import java.io.File; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -130,6 +131,24 @@ public interface CodegenConfig { CodegenOperation fromOperation(String resourcePath, String httpMethod, Operation operation, List servers); + /** + * Divides an operation into one operation per content-type when it exposes several request/response + * content-types with different schemas (opt-in, see {@code splitOperationsByContentType}). Each + * returned operation is self-contained and re-enters {@link #fromOperation}. When the option is off or + * no division applies, the operation is returned unchanged (as a singleton). {@code DefaultCodegen} + * implements the division; the default here keeps the operation whole so that an implementation not + * deriving from {@code DefaultCodegen} keeps compiling and simply opts out of the feature. + * + * @param openAPI the OpenAPI document + * @param path the resource path + * @param httpMethod the HTTP method + * @param operation the operation to (maybe) divide + * @return the operations to generate for {@code operation} (the operation itself when not divided) + */ + default List divideOperationsByContentType(OpenAPI openAPI, String path, String httpMethod, Operation operation) { + return Collections.singletonList(operation); + } + List fromSecurity(Map schemas); List fromServers(List servers); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java index ad507e2ff370..703d6927f561 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java @@ -156,6 +156,24 @@ public class CodegenConstants { public static final String PREPEND_FORM_OR_BODY_PARAMETERS = "prependFormOrBodyParameters"; public static final String PREPEND_FORM_OR_BODY_PARAMETERS_DESC = "Add form or body parameters to the beginning of the parameter list."; + public static final String SPLIT_OPERATIONS_BY_CONTENT_TYPE = "splitOperationsByContentType"; + + /** + * Extensions set on every operation produced by {@code splitOperationsByContentType}, describing where + * the variant sits in the content-type matrix so that a generator can merge the variants back into a + * single construct instead of emitting one method per combination. + *

+ * The {@code *-index} ones carry the 0-based rank of the variant's media-type in its axis, in the order + * the spec declares them, so a consumer never has to rely on the order operations happen to reach it in: + * rank 0 is that axis's default content-type, and the variant ranked 0 on both axes is the one a caller + * gets without asking. An axis that was not split has no media-type and ranks 0. + */ + public static final String X_CONTENT_TYPE_VARIANT_GROUP = "x-content-type-variant-group"; + public static final String X_CONTENT_TYPE_VARIANT_REQUEST = "x-content-type-variant-request"; + public static final String X_CONTENT_TYPE_VARIANT_RESPONSE = "x-content-type-variant-response"; + public static final String X_CONTENT_TYPE_VARIANT_REQUEST_INDEX = "x-content-type-variant-request-index"; + public static final String X_CONTENT_TYPE_VARIANT_RESPONSE_INDEX = "x-content-type-variant-response-index"; + public static final String USE_DATETIME_OFFSET = "useDateTimeOffset"; public static final String USE_DATETIME_OFFSET_DESC = "Use DateTimeOffset to model date-time properties"; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 945d828d9295..698299709c8d 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -425,6 +425,11 @@ public void processOpts() { convertPropertyToBooleanAndWriteBack(CodegenConstants.PREPEND_FORM_OR_BODY_PARAMETERS, this::setPrependFormOrBodyParameters); convertPropertyToBooleanAndWriteBack(CodegenConstants.ENSURE_UNIQUE_PARAMS, this::setEnsureUniqueParams); convertPropertyToBooleanAndWriteBack(CodegenConstants.ALLOW_UNICODE_IDENTIFIERS, this::setAllowUnicodeIdentifiers); + // splitOperationsByContentType is a global option rather than a generator one: the behaviour is + // language-neutral and applies to every generator alike, so it is read from the global properties + // (--global-property) and is deliberately absent from cliOptions. + setSplitOperationsByContentType(Boolean.parseBoolean( + GlobalSettings.getProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "false"))); convertPropertyToStringAndWriteBack(CodegenConstants.API_NAME_PREFIX, this::setApiNamePrefix); convertPropertyToStringAndWriteBack(CodegenConstants.API_NAME_SUFFIX, this::setApiNameSuffix); convertPropertyToStringAndWriteBack(CodegenConstants.MODEL_NAME_PREFIX, this::setModelNamePrefix); @@ -1081,6 +1086,264 @@ public void postProcessResponseWithProperty(CodegenResponse response, CodegenPro public void postProcessParameter(CodegenParameter parameter) { } + protected boolean splitOperationsByContentType = false; + + public void setSplitOperationsByContentType(boolean splitOperationsByContentType) { + this.splitOperationsByContentType = splitOperationsByContentType; + } + + /** + * When {@link CodegenConstants#SPLIT_OPERATIONS_BY_CONTENT_TYPE} is enabled, divides an operation whose + * request body and/or success response expose several content-types with different schemas + * into one operation per content-type (the cartesian product of the request and response axes, + * deduplicated by schema). Each variant is narrowed to a single content-type on each axis with a typed, + * collision-free operationId; {@code DefaultGenerator} processes each so it re-enters + * {@code fromOperation} and is typed natively by the target generator. This keeps the feature + * language-neutral: no per-language type re-derivation here. Returns the operation as a singleton when + * the option is off or no division applies. + *

+ * Every variant carries the {@code x-content-type-variant-*} extensions describing its place in the + * matrix, so a generator able to express the whole matrix in a single construct — TypeScript overloads, + * for instance — can merge the variants back together while keeping each one's natively resolved types. + */ + @Override + public List divideOperationsByContentType(OpenAPI openAPI, String path, String httpMethod, Operation operation) { + if (!splitOperationsByContentType || operation == null) { + return Collections.singletonList(operation); + } + RequestBody requestBody = ModelUtils.getReferencedRequestBody(openAPI, operation.getRequestBody()); + List requestAxis = axisOf(requestBody == null ? null : requestBody.getContent()); + + // Only the response the generator derives the return type from (the method response) is split, so the + // variants' return types and Accept headers stay consistent (see findMethodResponse). + String methodResponseCode = operation.getResponses() == null ? null : findMethodResponseCode(operation.getResponses()); + ApiResponse methodResponse = methodResponseCode == null ? null + : ModelUtils.getReferencedApiResponse(openAPI, operation.getResponses().get(methodResponseCode)); + List responseAxis = axisOf(methodResponse == null ? null : methodResponse.getContent()); + + if (requestAxis.size() == 1 && responseAxis.size() == 1) { + return Collections.singletonList(operation); // single content-type on both axes: nothing to divide + } + + // Both axes are in declaration order, so rank 0 is the default content-type, consistently with the + // rest of the generator: addConsumesInfo keeps that order and templates read consumes.0. + String baseId = getOrGenerateOperationId(operation, path, httpMethod); + List variants = new ArrayList<>(requestAxis.size() * responseAxis.size()); + for (Axis request : requestAxis) { + for (Axis response : responseAxis) { + Operation variant = buildOperationVariant(openAPI, operation, baseId, request, response, + methodResponseCode, methodResponse); + tagContentTypeVariant(variant, baseId, request, response); + variants.add(variant); + } + } + return variants; + } + + /** + * One position on a content-type axis. {@link Axis#NOT_SPLIT} is the single position of an axis that + * stays as it is; the others carry the media-type the variant is narrowed to, the token its operationId + * is built from, and the rank the spec declares that media-type at. + */ + private static final class Axis { + private static final Axis NOT_SPLIT = new Axis(null, null, 0); + + private final String mediaType; + private final String token; + private final int rank; + + private Axis(String mediaType, String token, int rank) { + this.mediaType = mediaType; + this.token = token; + this.rank = rank; + } + } + + /** + * The media-types of {@code content} deduplicated by resolved schema (two media-types sharing a schema + * collapse into the first one declared), kept in declaration order: that is the order the rest of the + * generator already treats as authoritative, so rank 0 is the content-type a caller gets by default. + * Returns a singleton {@link Axis#NOT_SPLIT} when fewer than two distinct schemas remain. + */ + private List axisOf(Content content) { + if (content == null || content.size() < 2) { + return Collections.singletonList(Axis.NOT_SPLIT); + } + List mediaTypes = new ArrayList<>(); + Set seenSchemas = new HashSet<>(); + for (Map.Entry entry : content.entrySet()) { + if (seenSchemas.add(schemaKey(entry.getValue() == null ? null : entry.getValue().getSchema()))) { + mediaTypes.add(entry.getKey()); + } + } + if (mediaTypes.size() < 2) { + return Collections.singletonList(Axis.NOT_SPLIT); + } + // the subtype alone identifies most media-types, but not all: text/csv and application/csv would + // both be Csv and give two variants the same operationId, so those fall back to the whole type + Map bySubtype = mediaTypes.stream() + .collect(Collectors.groupingBy(DefaultCodegen::subtypeToken, Collectors.counting())); + List axis = new ArrayList<>(mediaTypes.size()); + for (int rank = 0; rank < mediaTypes.size(); rank++) { + String mediaType = mediaTypes.get(rank); + String subtype = subtypeToken(mediaType); + axis.add(new Axis(mediaType, bySubtype.get(subtype) > 1 ? sanitizeToken(mediaType) : subtype, rank)); + } + return axis; + } + + /** + * Records where a variant sits in the content-type matrix so that a generator able to express the whole + * matrix in a single construct can merge the variants back together: the group they belong to, the + * media-type they were narrowed to on each axis (absent when that axis was not split) and its rank in + * that axis. See {@link CodegenConstants#X_CONTENT_TYPE_VARIANT_REQUEST_INDEX}. + */ + private static void tagContentTypeVariant(Operation variant, String group, Axis request, Axis response) { + Map extensions = variant.getExtensions(); + extensions.put(CodegenConstants.X_CONTENT_TYPE_VARIANT_GROUP, group); + if (request.mediaType != null) { + extensions.put(CodegenConstants.X_CONTENT_TYPE_VARIANT_REQUEST, request.mediaType); + } + if (response.mediaType != null) { + extensions.put(CodegenConstants.X_CONTENT_TYPE_VARIANT_RESPONSE, response.mediaType); + } + // the rank travels with the variant: the operations are reordered before they reach a generator, + // so their position in the list is no longer the order declared in the spec + extensions.put(CodegenConstants.X_CONTENT_TYPE_VARIANT_REQUEST_INDEX, request.rank); + extensions.put(CodegenConstants.X_CONTENT_TYPE_VARIANT_RESPONSE_INDEX, response.rank); + } + + /** + * Builds one operation variant narrowed to a single request and/or response media-type (a {@code null} + * media-type leaves that axis untouched), with a typed, collision-free operationId. + */ + private Operation buildOperationVariant(OpenAPI openAPI, Operation original, String baseId, + Axis request, Axis response, + String targetResponseCode, ApiResponse targetResponse) { + Operation variant = shallowCopyOperation(original); + + // typed, collision-free operationId: request -> "With", response -> "As" + StringBuilder operationId = new StringBuilder(baseId); + if (request.mediaType != null) { + operationId.append("With").append(camelize(request.token)); + } + if (response.mediaType != null) { + operationId.append("As").append(camelize(response.token)); + } + variant.setOperationId(operationId.toString()); + + if (request.mediaType != null) { + RequestBody requestBody = ModelUtils.getReferencedRequestBody(openAPI, original.getRequestBody()); + variant.setRequestBody(narrowRequestBody(requestBody, request.mediaType)); + } + if (response.mediaType != null) { + variant.setResponses(narrowResponses(original.getResponses(), targetResponseCode, targetResponse, response.mediaType)); + } + return variant; + } + + /** + * Copies an {@link Operation} one level deep: the copy gets its own parameter and extension lists, + * which the split writes to, and shares everything below — schemas above all, which it only reads. + * A deep copy would have to round-trip through the mapper, which drops any schema whose type is not + * a standard OpenAPI < 3.1 one. + */ + private Operation shallowCopyOperation(Operation source) { + Operation copy = new Operation(); + copy.setTags(source.getTags()); + copy.setSummary(source.getSummary()); + copy.setDescription(source.getDescription()); + copy.setExternalDocs(source.getExternalDocs()); + copy.setOperationId(source.getOperationId()); + copy.setParameters(source.getParameters() == null ? null : new ArrayList<>(source.getParameters())); + copy.setRequestBody(source.getRequestBody()); + copy.setResponses(source.getResponses()); + copy.setCallbacks(source.getCallbacks()); + copy.setDeprecated(source.getDeprecated()); + copy.setSecurity(source.getSecurity() == null ? null : new ArrayList<>(source.getSecurity())); + copy.setServers(source.getServers()); + // a non-null extensions map: the split writes the variant's place in the matrix into it, and + // generators (SpringCodegen for one) read it without null-guards + copy.setExtensions(source.getExtensions() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source.getExtensions())); + return copy; + } + + private RequestBody narrowRequestBody(RequestBody source, String mediaType) { + RequestBody copy = new RequestBody(); + copy.setDescription(source.getDescription()); + copy.setRequired(source.getRequired()); + copy.setExtensions(source.getExtensions()); + copy.setContent(singleContent(source.getContent(), mediaType)); + return copy; + } + + private ApiResponses narrowResponses(ApiResponses responses, String targetCode, ApiResponse targetResponse, String mediaType) { + ApiResponses copy = new ApiResponses(); + copy.setExtensions(responses.getExtensions()); + for (Map.Entry entry : responses.entrySet()) { + if (entry.getKey().equals(targetCode)) { + copy.addApiResponse(entry.getKey(), narrowApiResponse(targetResponse, mediaType)); + } else { + copy.addApiResponse(entry.getKey(), entry.getValue()); + } + } + return copy; + } + + private ApiResponse narrowApiResponse(ApiResponse source, String mediaType) { + ApiResponse copy = new ApiResponse(); + copy.setDescription(source.getDescription()); + copy.setHeaders(source.getHeaders()); + copy.setLinks(source.getLinks()); + copy.setExtensions(source.getExtensions()); + copy.setContent(singleContent(source.getContent(), mediaType)); + return copy; + } + + /** A new {@link Content} holding only {@code mediaType} taken from {@code source}. */ + private static Content singleContent(Content source, String mediaType) { + Content content = new Content(); + content.addMediaType(mediaType, source.get(mediaType)); + return content; + } + + /** Stable identity key for a schema: its {@code $ref} when present, else a structural key. */ + private static String schemaKey(Schema schema) { + if (schema == null) { + return "null"; + } + if (schema.get$ref() != null) { + return schema.get$ref(); + } + StringBuilder key = new StringBuilder(); + key.append(ModelUtils.getType(schema)).append('|').append(schema.getFormat()); + if (schema.getItems() != null) { + key.append("|items=").append(schemaKey(schema.getItems())); + } + // the property names too: without them two different inline object schemas share a key, and the + // media-type of the second one is dropped from the generated client with nothing said + if (schema.getProperties() != null) { + key.append("|props=").append(new TreeSet<>(schema.getProperties().keySet())); + } + if (schema.getAdditionalProperties() instanceof Schema) { + key.append("|addProps=").append(schemaKey((Schema) schema.getAdditionalProperties())); + } + return key.toString(); + } + + /** Whole media-type reduced to an identifier, e.g. {@code text_csv} from {@code text/csv}. */ + private static String sanitizeToken(String mediaType) { + return mediaType.replaceAll("\\+.*$", "").replaceAll("[^a-zA-Z0-9]+", "_"); + } + + /** Token derived from a media-type subtype, e.g. {@code Directlog} from {@code application/directlog}. */ + private static String subtypeToken(String mediaType) { + String subtype = mediaType.substring(mediaType.indexOf('/') + 1); + subtype = subtype.replaceAll("\\+.*$", ""); // drop structured suffix (+json, +xml, ...) + subtype = subtype.replaceAll("[^a-zA-Z0-9]+", "_"); + return subtype; + } + //override with any special handling of the entire OpenAPI spec document @Override @SuppressWarnings("unused") @@ -1864,7 +2127,6 @@ public DefaultCodegen() { // option to change the order of form/body parameter cliOptions.add(CliOption.newBoolean(CodegenConstants.PREPEND_FORM_OR_BODY_PARAMETERS, CodegenConstants.PREPEND_FORM_OR_BODY_PARAMETERS_DESC).defaultValue(Boolean.FALSE.toString())); - // option to change how we process + set the data in the discriminator mapping CliOption legacyDiscriminatorBehaviorOpt = CliOption.newBoolean(CodegenConstants.LEGACY_DISCRIMINATOR_BEHAVIOR, CodegenConstants.LEGACY_DISCRIMINATOR_BEHAVIOR_DESC).defaultValue(Boolean.TRUE.toString()); Map legacyDiscriminatorBehaviorOpts = new HashMap<>(); @@ -4395,6 +4657,21 @@ protected void setNonArrayMapProperty(CodegenProperty property, String type) { * @return default method response or null if not found */ protected ApiResponse findMethodResponse(ApiResponses responses) { + String code = findMethodResponseCode(responses); + if (code == null) { + return null; + } + return ModelUtils.getReferencedApiResponse(openAPI, responses.get(code)); + } + + /** + * Returns the response code the operation's return type is derived from: the lowest 2xx code, or + * {@code "default"} when no 2xx is present. + * + * @param responses the API responses of an operation + * @return the selected response code, or {@code null} if there is no success/default response + */ + protected String findMethodResponseCode(ApiResponses responses) { String code = null; for (String responseCode : responses.keySet()) { if (responseCode.startsWith("2") || responseCode.equals("default")) { @@ -4403,10 +4680,7 @@ protected ApiResponse findMethodResponse(ApiResponses responses) { } } } - if (code == null) { - return null; - } - return ModelUtils.getReferencedApiResponse(openAPI, responses.get(code)); + return code; } /** diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index 941a3cf4d0e3..6636187fec0a 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -1532,6 +1532,17 @@ private void processOperation(String resourcePath, String httpMethod, Operation return; } + // splitOperationsByContentType: an operation that exposes several content-types with different + // schemas is divided into one self-contained single-content-type Operation per content-type, each + // re-entering the pipeline so it is typed natively by the generator. + List contentTypeVariants = config.divideOperationsByContentType(openAPI, resourcePath, httpMethod, operation); + if (contentTypeVariants.size() > 1) { + for (Operation variant : contentTypeVariants) { + processOperation(resourcePath, httpMethod, variant, operations, path); + } + return; + } + if (GlobalSettings.getProperty("debugOperations") != null) { LOGGER.info("processOperation: resourcePath= {}\t;{} {}\n", resourcePath, httpMethod, operation); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java index 5d9c7091ad49..f713a7e677d7 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java @@ -36,9 +36,13 @@ import org.openapitools.codegen.model.OperationsMap; import org.openapitools.codegen.templating.mustache.IndentedLambda; import org.openapitools.codegen.utils.ModelUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.stream.Collectors; import static java.util.Objects.nonNull; @@ -46,12 +50,15 @@ import static org.openapitools.codegen.CodegenConstants.ENUM_VARS; import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER; import static org.openapitools.codegen.utils.EnumUtils.getEnumVarsAsString; +import static org.openapitools.codegen.utils.OnceLogger.once; import static org.openapitools.codegen.utils.StringUtils.*; /** *

Mustache templates are located in {@code src/main/resources/typescript-fetch/}. */ public class TypeScriptFetchClientCodegen extends AbstractTypeScriptClientCodegen { + private final Logger LOGGER = LoggerFactory.getLogger(TypeScriptFetchClientCodegen.class); + public static final String NPM_REPOSITORY = "npmRepository"; public static final String WITH_INTERFACES = "withInterfaces"; public static final String USE_SINGLE_REQUEST_PARAMETER = "useSingleRequestParameter"; @@ -251,6 +258,9 @@ public boolean isUniqueIdAccordingToNameSuffix(String name) { @Override public void processOpts() { super.processOpts(); + // runtime.ts only carries the ExclusiveUnion helper when the option that needs it is on, so an + // ordinary client is byte-for-byte what it was + additionalProperties.put(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, splitOperationsByContentType); additionalProperties.put("isOriginalModelPropertyNaming", getModelPropertyNaming() == CodegenConstants.MODEL_PROPERTY_NAMING_TYPE.original); additionalProperties.put("modelPropertyNaming", getModelPropertyNaming().name()); @@ -378,6 +388,9 @@ protected ImmutableMap.Builder addMustacheLambdas() { ImmutableMap.Builder lambdas = super.addMustacheLambdas(); lambdas.put("indented_star_1", new IndentedLambda(1, " ", "* ", false, false)); lambdas.put("indented_star_4", new IndentedLambda(5, " ", "* ", false, false)); + // the shared indented_N lambdas indent blank lines too, leaving trailing whitespace; these skip + // them, so a flush-left partial can be shared between call sites at different depths + lambdas.put("indented_8_skip_blank", new IndentedLambda(8, " ", false, true)); return lambdas; } @@ -787,6 +800,11 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L this.addOperationObjectResponseInformation(operations); this.addOperationPrefixParameterInterfacesInformation(operations); this.addMultipartFileArrayApiExampleValues(operations); + // last: the merge drops the non-default variants from the list while the merged operation keeps + // referencing their parameters and return types. Every pass above has to have seen them by then - + // updateOperationParameterForEnum, for one, is what prefixes an enum parameter's type name with the + // operation's, and a variant it never visited would be left referencing a type nobody declares. + this.mergeContentTypeVariants(operations); return operations; } @@ -976,14 +994,285 @@ private boolean processCodegenProperty(ExtendedCodegenProperty var, String paren private void escapeOperationIds(OperationsMap operations) { for (CodegenOperation _op : operations.getOperations().getOperation()) { ExtendedCodegenOperation op = (ExtendedCodegenOperation) _op; - String param = op.operationIdCamelCase + "Request"; - if (op.imports.contains(param)) { + if (op.imports.contains(op.operationIdCamelCase + "Request")) { // we import a model with the same name as the generated operation, escape it - op.operationIdCamelCase += "Operation"; - op.operationIdLowerCase += "operation"; - op.operationIdSnakeCase += "_operation"; + escapeOperationId(op); + } + } + } + + private static void escapeOperationId(CodegenOperation op) { + op.operationIdCamelCase += "Operation"; + op.operationIdLowerCase += "operation"; + op.operationIdSnakeCase += "_operation"; + } + + /** + * Merges the operations produced by the global {@code splitOperationsByContentType} option back into a + * single method per original operation. + *

+ * The split emits one operation per (request content-type, response content-type) pair so that every + * variant is typed natively by the generator. Statically-typed languages need those separate methods, + * but TypeScript can express the whole matrix at once: the variants are collapsed into one method whose + * request type is a union discriminated by {@code contentType}, and whose return type is selected by + * overloads on {@code accept}. Each variant keeps its own natively resolved body and return types, which + * is exactly what the split computed. + *

+ * The one shape the merged form cannot express is {@code useSingleRequestParameter} off: with the + * parameters spread over the signature there is no request object to carry the discriminant. Those + * operations are left split, with a warning. + */ + /** Media-type the merged operation sends as Accept when the caller does not pick one. */ + private static final String X_CONTENT_TYPE_DEFAULT_RESPONSE = "x-content-type-default-response"; + + private void mergeContentTypeVariants(OperationsMap operations) { + if (!splitOperationsByContentType) { + return; + } + List allOperations = operations.getOperations().getOperation(); + if (!this.getUseSingleRequestParameter()) { + // the merged form carries the discriminant on the request object; with the parameters spread + // over the signature there is nothing to discriminate on + if (allOperations.stream().anyMatch(op -> op.vendorExtensions.containsKey(CodegenConstants.X_CONTENT_TYPE_VARIANT_GROUP))) { + once(LOGGER).warn("`{}` is off: content-type variants are generated as separate methods rather " + + "than merged into one.", USE_SINGLE_REQUEST_PARAMETER); + } + return; + } + + // LinkedHashMap only to keep the groups themselves in a stable order; within a group the axis order + // comes from the rank each variant carries, not from its position here - see variantsByRank. + Map> groups = new LinkedHashMap<>(); + for (CodegenOperation op : allOperations) { + Object group = op.vendorExtensions.get(CodegenConstants.X_CONTENT_TYPE_VARIANT_GROUP); + if (group != null) { + groups.computeIfAbsent(group + " " + op.httpMethod + " " + op.path, k -> new ArrayList<>()).add(op); + } + } + + // identity-based: CodegenOperation.hashCode() walks the whole operation, and two variants of the + // same operation are very nearly equal + Set superseded = Collections.newSetFromMap(new IdentityHashMap<>()); + for (List variants : groups.values()) { + if (variants.size() < 2) { + continue; + } + List requestVariants = requestVariantsOf(variants); + List responseVariants = responseVariantsOf(variants); + + // the surviving operation is the one a caller gets without asking: rank 0 on both axes + CodegenOperation base = variants.stream() + .filter(TypeScriptFetchClientCodegen::isDefaultVariant) + .findFirst() + .orElse(variants.get(0)); + + renameToGroupOperationId(base, variants); + ExtendedCodegenOperation merged = (ExtendedCodegenOperation) base; + merged.contentTypeMerged = true; + merged.contentTypeRequestVariants = requestVariants; + merged.contentTypeResponseVariants = responseVariants; + merged.contentTypeMergedEnumParams = enumParamsOf(variants); + merged.hasContentTypeRequestVariants = requestVariants.size() > 1; + merged.hasContentTypeResponseVariants = responseVariants.size() > 1; + base.vendorExtensions.put(X_CONTENT_TYPE_DEFAULT_RESPONSE, + responseVariants.get(0).mediaType); + if (responseVariants.size() > 1) { + // the same variants, but ordered for an if / else if / else chain: the default content-type + // is the fallback, so it comes last there rather than first. Only meaningful beyond one + // variant - a single one would be both the chain's first and last branch. + List dispatch = new ArrayList<>(responseVariants); + Collections.rotate(dispatch, -1); + merged.contentTypeResponseDispatch = dispatch; + } + + // the split narrowed each variant to a single media type per axis; the merged operation speaks + // them all again, so its documentation says so. apis.mustache reads consumes only where the + // request axis was not split - a case where this union is the single value anyway - and never + // reads produces, so this is documentation only. + base.consumes = mediaTypesOf(requestVariants, v -> v.consumes); + base.produces = mediaTypesOf(responseVariants, v -> v.produces); + + variants.stream().filter(op -> op != base).forEach(superseded::add); + } + + // identity-based membership, so a variant is dropped without being compared field by field + allOperations.removeAll(superseded); + } + + /** + * The media types the variants of one axis declare, in the axis order, without repeating one two + * variants happen to share. + */ + private static List> mediaTypesOf(List axis, + Function>> field) { + Map> byMediaType = new LinkedHashMap<>(); + for (ContentTypeVariant variant : axis) { + List> declared = field.apply(variant); + if (declared == null) { + continue; + } + for (Map entry : declared) { + byMediaType.putIfAbsent(entry.get("mediaType"), entry); + } + } + return byMediaType.isEmpty() ? null : new ArrayList<>(byMediaType.values()); + } + + /** + * One entry per distinct request content-type, in declaration order, carrying the body that content-type + * expects as the generator resolved it. A single-element list means the request axis was not split. + */ + private List requestVariantsOf(List variants) { + return variantsByRank(variants, CodegenConstants.X_CONTENT_TYPE_VARIANT_REQUEST, + CodegenConstants.X_CONTENT_TYPE_VARIANT_REQUEST_INDEX, (entry, variant) -> { + entry.allParams = variant.allParams; + entry.bodyParam = variant.bodyParam; + // a form or multipart variant carries its body in individual parameters rather than in a + // body parameter: the template assembles it per content-type, from these + entry.hasFormParams = variant.getHasFormParams(); + entry.formParams = variant.formParams; + entry.consumes = variant.consumes; + }); + } + + /** + * One entry per distinct response content-type, in declaration order, carrying the return type the + * generator resolved for it and the flags {@code apisResponseVariantValue.mustache} needs to pick a + * deserialiser. A single-element list means the response axis was not split. + */ + private List responseVariantsOf(List variants) { + return variantsByRank(variants, CodegenConstants.X_CONTENT_TYPE_VARIANT_RESPONSE, + CodegenConstants.X_CONTENT_TYPE_VARIANT_RESPONSE_INDEX, (entry, variant) -> { + entry.returnType = variant.returnType; + entry.returnBaseType = variant.returnBaseType; + entry.isResponseFile = variant.isResponseFile; + entry.returnTypeIsPrimitive = variant.returnTypeIsPrimitive; + entry.returnSimpleType = variant.returnSimpleType; + entry.isArray = variant.isArray; + entry.isMap = variant.isMap; + entry.uniqueItems = variant.uniqueItems; + entry.produces = variant.produces; + }); + } + + /** + * Groups the variants by their rank on one axis, one entry per media-type, ordered by the rank the split + * recorded — the order the spec declares the content-types in. The rank is read from the variants rather + * than from their position in the list, which is not the split's: operations are reordered on their way + * to the generator. + */ + private List variantsByRank(List variants, String axisExtension, + String indexExtension, + BiConsumer describe) { + Map byRank = new TreeMap<>(); + for (CodegenOperation variant : variants) { + Integer rank = (Integer) variant.vendorExtensions.get(indexExtension); + byRank.computeIfAbsent(rank, key -> { + ContentTypeVariant entry = new ContentTypeVariant(); + // an axis left unsplit has no media-type: every variant then represents the same, single one + entry.mediaType = (String) variant.vendorExtensions.get(axisExtension); + entry.isDefault = key == 0; + describe.accept(entry, variant); + return entry; + }); + } + return new ArrayList<>(byRank.values()); + } + + /** + * One content-type of a merged operation, on one axis. Read by the templates through their own field + * names, so a mistyped one fails to compile rather than rendering as nothing. + */ + public static class ContentTypeVariant { + public String mediaType; + public boolean isDefault; + + // request axis + public List allParams, formParams; + public CodegenParameter bodyParam; + public boolean hasFormParams; + public List> consumes, produces; + + // response axis + public boolean isResponseFile, returnTypeIsPrimitive, returnSimpleType, isArray, isMap, uniqueItems; + public String returnType, returnBaseType; + } + + /** + * Renames the enum types of every variant's parameters after the merged operation rather than after the + * variant they came from. + *

+ * {@code updateOperationParameterForEnum} prefixes an enum parameter's type with its operation's name, so + * that two operations sharing a parameter name do not declare the same type twice. It ran before the + * merge, when each variant still had its own name — but the enum is declared once, under the merged + * operation's name, and the request union references every variant's parameters. + */ + private void reprefixEnumParameters(List variants, String mergedIdCamelCase) { + for (CodegenOperation variant : variants) { + for (CodegenParameter param : variant.allParams) { + if (Boolean.TRUE.equals(param.isEnum) && param.datatypeWithEnum != null) { + param.datatypeWithEnum = param.datatypeWithEnum.replace( + variant.operationIdCamelCase + param.enumName, mergedIdCamelCase + param.enumName); + } + } + } + } + + /** + * Every enum parameter of every variant, deduplicated by enum type name, in variant order. The request + * union references them all, but only the surviving variant's parameters remain reachable through + * allParams — the enum declarations are emitted from this list instead. Two variants declaring the same + * enum name (a query parameter they share, most commonly) collapse into one declaration. + */ + private static List enumParamsOf(List variants) { + Map byEnumName = new LinkedHashMap<>(); + for (CodegenOperation variant : variants) { + for (CodegenParameter param : variant.allParams) { + if (Boolean.TRUE.equals(param.isEnum) && param.enumName != null) { + byEnumName.putIfAbsent(param.enumName, param); + } } } + return new ArrayList<>(byEnumName.values()); + } + + /** The variant a caller gets without asking: rank 0 on both axes. */ + private static boolean isDefaultVariant(CodegenOperation op) { + return Integer.valueOf(0).equals(op.vendorExtensions.get(CodegenConstants.X_CONTENT_TYPE_VARIANT_REQUEST_INDEX)) + && Integer.valueOf(0).equals(op.vendorExtensions.get(CodegenConstants.X_CONTENT_TYPE_VARIANT_RESPONSE_INDEX)); + } + + /** + * Gives a merged operation back the name of the operation it was split from, and moves the variants' enum + * types onto that name. + *

+ * The two belong together: reprefixEnumParameters reads each variant's current name, so it has to run + * before this one is renamed. Splitting them across two calls made that ordering invisible, and easy to + * get backwards. + */ + private void renameToGroupOperationId(CodegenOperation operation, List variants) { + String group = (String) operation.vendorExtensions.get(CodegenConstants.X_CONTENT_TYPE_VARIANT_GROUP); + String operationId = toOperationId(group); + String camelCase = camelize(operationId); + // escapeOperationIds ran on the variants' suffixed names, so it could not see the merged name: + // its escape is replayed here. The merged method references every variant's types, hence the + // union of their imports. + String requestName = camelCase + "Request"; + boolean collides = variants.stream().anyMatch(variant -> variant.imports.contains(requestName)); + + // before the rename: reprefixEnumParameters matches on each variant's current name, and the merged + // operation is one of the variants + reprefixEnumParameters(variants, collides ? camelCase + "Operation" : camelCase); + + operation.operationIdOriginal = group; + operation.operationId = operationId; + operation.nickname = operationId; + operation.operationIdCamelCase = camelCase; + operation.operationIdLowerCase = operationId.toLowerCase(Locale.ROOT); + operation.operationIdSnakeCase = underscore(operationId); + if (collides) { + escapeOperationId(operation); + } } private void addOperationModelImportInformation(OperationsMap operations) { @@ -1488,6 +1777,23 @@ public class ExtendedCodegenOperation extends CodegenOperation { boolean hasReturnPassthroughVoid, returnTypeSupportsEntities, returnTypeIsModel, returnTypeIsArray; String returnTypeAlternate, returnBaseTypeAlternate, returnPassthrough; + /** + * Set by {@link TypeScriptFetchClientCodegen#mergeContentTypeVariants} on an operation merged back + * from its content-type variants. Fields rather than vendor extensions: a mistyped `x-` key renders + * as nothing at all in a template, and {@link CodegenOperation#hashCode()} walks the extensions map, + * which these lists have no business being dragged through. + */ + public boolean contentTypeMerged, hasContentTypeRequestVariants, hasContentTypeResponseVariants; + public List contentTypeRequestVariants, contentTypeResponseVariants; + /** The response variants ordered for an if / else if / else chain: the default one comes last. */ + public List contentTypeResponseDispatch; + /** + * Every enum parameter the merged request union references, across all the variants, deduplicated + * by the name of the type it declares. The enum declaration block walks this rather than allParams, + * which only holds the surviving variant's parameters. + */ + public List contentTypeMergedEnumParams; + public ExtendedCodegenOperation(CodegenOperation o) { super(); diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache index 97fb15df5253..9958fe4a7b06 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache @@ -22,6 +22,19 @@ import { {{#operations}} {{#operation}} +{{#contentTypeMerged}} +{{! the operation accepts several request content-types with different schemas: one union member per + content-type, discriminated by `contentType`, whose value selects the body's type }} +{{! runtime.ExclusiveUnion makes the members mutually exclusive: without it a value carrying another + member's body satisfies this one, and the body goes out under the wrong content-type }} +export type {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request = {{#hasContentTypeRequestVariants}}runtime.ExclusiveUnion<{{/hasContentTypeRequestVariants}} +{{#contentTypeRequestVariants}} + | { {{#hasContentTypeRequestVariants}}contentType{{#isDefault}}?{{/isDefault}}: '{{mediaType}}'; {{/hasContentTypeRequestVariants}}{{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{#hasReadOnly}}Omit<{{{dataType}}}, {{#readOnlyVars}}'{{baseName}}'{{^-last}}|{{/-last}}{{/readOnlyVars}}>{{/hasReadOnly}}{{^hasReadOnly}}{{{dataType}}}{{/hasReadOnly}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}; {{/allParams}}} +{{/contentTypeRequestVariants}} +{{#hasContentTypeRequestVariants}}>{{/hasContentTypeRequestVariants}}; + +{{/contentTypeMerged}} +{{^contentTypeMerged}} {{#allParams.0}} export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request { {{#allParams}} @@ -36,6 +49,7 @@ export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterIn } {{/allParams.0}} +{{/contentTypeMerged}} {{/operation}} {{/operations}} {{#withInterfaces}} @@ -60,7 +74,7 @@ export interface {{classname}}Interface { * @throws {RequiredError} * @memberof {{classname}}Interface */ - {{nickname}}RequestOpts({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}): Promise; + {{nickname}}RequestOpts({{#contentTypeMerged}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{#hasContentTypeResponseVariants}} & { accept?: string }{{/hasContentTypeResponseVariants}}{{/contentTypeMerged}}{{^contentTypeMerged}}{{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}{{/contentTypeMerged}}): Promise; {{/withRequestOptsInInterface}} /** @@ -78,7 +92,14 @@ export interface {{classname}}Interface { * @throws {RequiredError} * @memberof {{classname}}Interface */ + {{#hasContentTypeResponseVariants}} + {{#contentTypeResponseVariants}} + {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + {{/contentTypeResponseVariants}} + {{/hasContentTypeResponseVariants}} + {{^hasContentTypeResponseVariants}} {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + {{/hasContentTypeResponseVariants}} /** {{#notes}} @@ -95,7 +116,14 @@ export interface {{classname}}Interface { {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; {{/useSingleRequestParameter}} {{#useSingleRequestParameter}} + {{#hasContentTypeResponseVariants}} + {{#contentTypeResponseVariants}} + {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{{returnType}}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{/contentTypeResponseVariants}} + {{/hasContentTypeResponseVariants}} + {{^hasContentTypeResponseVariants}} {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{/hasContentTypeResponseVariants}} {{/useSingleRequestParameter}} {{/operation}} @@ -121,7 +149,30 @@ export class {{classname}} extends runtime.BaseAPI { * @deprecated {{/isDeprecated}} */ - async {{nickname}}RequestOpts({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}): Promise { + async {{nickname}}RequestOpts({{#contentTypeMerged}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{#hasContentTypeResponseVariants}} & { accept?: string }{{/hasContentTypeResponseVariants}}{{/contentTypeMerged}}{{^contentTypeMerged}}{{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{/allParams.0}}{{/contentTypeMerged}}): Promise { + {{! the conditional has to sit outside the parameter loop below: in a parameter's context, + `vendorExtensions` resolves to that parameter's own map and never to the operation's }} + {{#hasContentTypeRequestVariants}} + {{#allParams}} + {{#required}} + {{! the body and the form params only exist on one member of the request union: indexing them + here would not type-check, would narrow the union for everything below - and would demand + of every content-type a parameter only one of them has. Guarded in the switch instead. }} + {{^isBodyParam}} + {{^isFormParam}} + if (requestParameters['{{paramName}}'] == null) { + throw new runtime.RequiredError( + '{{paramName}}', + 'Required parameter "{{paramName}}" was null or undefined when calling {{nickname}}().' + ); + } + + {{/isFormParam}} + {{/isBodyParam}} + {{/required}} + {{/allParams}} + {{/hasContentTypeRequestVariants}} + {{^hasContentTypeRequestVariants}} {{#allParams}} {{#required}} if (requestParameters['{{paramName}}'] {{#isBodyParam}}{{#isNullable}}=== undefined{{/isNullable}}{{^isNullable}}== null{{/isNullable}}{{/isBodyParam}}{{^isBodyParam}}== null{{/isBodyParam}}) { @@ -133,6 +184,7 @@ export class {{classname}} extends runtime.BaseAPI { {{/required}} {{/allParams}} + {{/hasContentTypeRequestVariants}} const queryParameters: any = {}; {{#queryParams}} @@ -168,6 +220,15 @@ export class {{classname}} extends runtime.BaseAPI { {{/queryParams}} const headerParameters: runtime.HTTPHeaders = {}; + {{! Content-Type is set per content-type in the switch below, where the body is built: a multipart + body must not set it at all, fetch adds it with the boundary it generates }} + {{! only where the response axis was actually divided: elsewhere the caller may have pinned an + Accept on the Configuration, and an operation-level one silently wins over it }} + {{#hasContentTypeResponseVariants}} + headerParameters['Accept'] = requestParameters.accept ?? '{{vendorExtensions.x-content-type-default-response}}'; + + {{/hasContentTypeResponseVariants}} + {{^hasContentTypeRequestVariants}} {{#bodyParam}} {{^consumes}} headerParameters['Content-Type'] = 'application/json'; @@ -178,6 +239,7 @@ export class {{classname}} extends runtime.BaseAPI { {{/consumes.0}} {{/bodyParam}} + {{/hasContentTypeRequestVariants}} {{#headerParams}} {{#isArray}} if (requestParameters['{{paramName}}'] != null) { @@ -232,75 +294,15 @@ export class {{classname}} extends runtime.BaseAPI { {{/isOAuth}} {{/authMethods}} + {{! a request-split operation assembles its form body inside the content-type switch instead, where + the union is narrowed; emitting it here too would shadow those declarations }} + {{^hasContentTypeRequestVariants}} {{#hasFormParams}} - const consumes: runtime.Consume[] = [ - {{#consumes}} - { contentType: '{{{mediaType}}}' }, - {{/consumes}} - ]; - // @ts-ignore: canConsumeForm may be unused - const canConsumeForm = runtime.canConsumeForm(consumes); - - let formParams: { append(param: string, value: any): any }; - let useForm = false; - {{#formParams}} - {{#isFile}} - // use FormData to transmit files using content-type "multipart/form-data" - useForm = canConsumeForm; - {{/isFile}} - {{/formParams}} - if (useForm) { - formParams = new FormData(); - } else { - formParams = new URLSearchParams(); - } - - {{#formParams}} - {{#isArray}} - if (requestParameters['{{paramName}}'] != null) { - {{#isCollectionFormatMulti}} - requestParameters['{{paramName}}'].forEach((element) => { - formParams.append('{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}', element as any); - }) - {{/isCollectionFormatMulti}} - {{^isCollectionFormatMulti}} - formParams.append('{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}', {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters['{{paramName}}']{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"])); - {{/isCollectionFormatMulti}} - } - - {{/isArray}} - {{^isArray}} - if (requestParameters['{{paramName}}'] != null) { - {{#isDateTimeType}} - formParams.append('{{baseName}}', (requestParameters['{{paramName}}'] as any).toISOString()); - {{/isDateTimeType}} - {{^isDateTimeType}} - {{#isPrimitiveType}} - formParams.append('{{baseName}}', requestParameters['{{paramName}}'] as any); - {{/isPrimitiveType}} - {{^isPrimitiveType}} - {{#isEnumRef}} - formParams.append('{{baseName}}', requestParameters['{{paramName}}'] as any); - {{/isEnumRef}} - {{^isEnumRef}} - {{^withoutRuntimeChecks}} - {{^isContainer}} - formParams.append('{{baseName}}', new Blob([JSON.stringify({{#isFreeFormObject}}runtime.anyToJSON{{/isFreeFormObject}}{{^isFreeFormObject}}{{{dataType}}}ToJSON{{/isFreeFormObject}}(requestParameters['{{paramName}}']))], { type: "application/json", })); - {{/isContainer}} - {{#isContainer}} - formParams.append('{{baseName}}', new Blob([JSON.stringify(requestParameters['{{paramName}}'])], { type: "application/json", })); - {{/isContainer}} - {{/withoutRuntimeChecks}}{{#withoutRuntimeChecks}} - formParams.append('{{baseName}}', new Blob([JSON.stringify(requestParameters['{{paramName}}'])], { type: "application/json", })); - {{/withoutRuntimeChecks}} - {{/isEnumRef}} - {{/isPrimitiveType}} - {{/isDateTimeType}} - } - - {{/isArray}} - {{/formParams}} +{{! the partial is already written at this method's indentation, so it is included as-is; the + content-type switch, which is deeper, is the one that has to re-indent it }} +{{>apisFormParams}} {{/hasFormParams}} + {{/hasContentTypeRequestVariants}} let urlPath = `{{{path}}}`; {{#pathParams}} @@ -325,6 +327,37 @@ export class {{classname}} extends runtime.BaseAPI { {{/isDateTimeType}} {{/pathParams}} + {{#hasContentTypeRequestVariants}} + {{! `contentType` selects the body's type, so narrow on it before serialising }} + let body: any; + switch (requestParameters.contentType) { + {{#contentTypeRequestVariants}} + {{^isDefault}} + case '{{mediaType}}': { +{{>apisContentTypeVariantBody}} + break; + } + {{/isDefault}} + {{/contentTypeRequestVariants}} + {{#contentTypeRequestVariants}} + {{#isDefault}} + default: { +{{>apisContentTypeVariantBody}} + } + {{/isDefault}} + {{/contentTypeRequestVariants}} + } + + return { + path: urlPath, + method: '{{httpMethod}}', + headers: headerParameters, + query: queryParameters, + body: body, + }; + } + {{/hasContentTypeRequestVariants}} + {{^hasContentTypeRequestVariants}} return { path: urlPath, method: '{{httpMethod}}', @@ -332,27 +365,7 @@ export class {{classname}} extends runtime.BaseAPI { query: queryParameters, {{#hasBodyParam}} {{#bodyParam}} - {{#isContainer}} - {{^withoutRuntimeChecks}} - body: requestParameters['{{paramName}}']{{#isArray}}{{#items}}{{^isPrimitiveType}}!.map({{datatype}}ToJSON){{/isPrimitiveType}}{{/items}}{{/isArray}}, - {{/withoutRuntimeChecks}} - {{#withoutRuntimeChecks}} - body: requestParameters['{{paramName}}'], - {{/withoutRuntimeChecks}} - {{/isContainer}} - {{^isContainer}} - {{^isPrimitiveType}} - {{^withoutRuntimeChecks}} - body: {{dataType}}ToJSON(requestParameters['{{paramName}}']), - {{/withoutRuntimeChecks}} - {{#withoutRuntimeChecks}} - body: requestParameters['{{paramName}}'], - {{/withoutRuntimeChecks}} - {{/isPrimitiveType}} - {{#isPrimitiveType}} - body: requestParameters['{{paramName}}'] as any, - {{/isPrimitiveType}} - {{/isContainer}} + body: {{>apisRequestBodyValue}}, {{/bodyParam}} {{/hasBodyParam}} {{#hasFormParams}} @@ -360,6 +373,7 @@ export class {{classname}} extends runtime.BaseAPI { {{/hasFormParams}} }; } + {{/hasContentTypeRequestVariants}} /** {{#notes}} @@ -372,6 +386,40 @@ export class {{classname}} extends runtime.BaseAPI { * @deprecated {{/isDeprecated}} */ + {{#hasContentTypeResponseVariants}} + {{! one overload per response content-type: `accept` selects the return type. The implementation + signature unions them and is not callable from outside. }} + {{#contentTypeResponseVariants}} + async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + {{/contentTypeResponseVariants}} + async {{nickname}}Raw(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.{{nickname}}RequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + {{! dispatch on the content-type the server actually returned, not on the requested `accept`: + the header is a request, and a server is free not to honour it }} + {{! type/subtype only, compared exactly: startsWith would let application/json win over + application/json-patch+json, and a `; charset=` parameter would defeat an equality test. + Lower-cased on both sides because a media type is case-insensitive (RFC 9110). }} + const responseContentType = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + {{#contentTypeResponseDispatch}} + {{#-first}} + if (responseContentType === '{{#lambda.lowercase}}{{mediaType}}{{/lambda.lowercase}}') { + {{/-first}} + {{^-first}} + {{^-last}} + } else if (responseContentType === '{{#lambda.lowercase}}{{mediaType}}{{/lambda.lowercase}}') { + {{/-last}} + {{/-first}} + {{#-last}} + } else { + {{/-last}} +{{>apisResponseVariantValue}} + {{/contentTypeResponseDispatch}} + } + } + {{/hasContentTypeResponseVariants}} + {{^hasContentTypeResponseVariants}} async {{nickname}}Raw({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { const requestOptions = await this.{{nickname}}RequestOpts({{#allParams.0}}requestParameters{{/allParams.0}}); const response = await this.request(requestOptions, initOverrides); @@ -415,6 +463,7 @@ export class {{classname}} extends runtime.BaseAPI { return new runtime.VoidApiResponse(response); {{/returnType}} } + {{/hasContentTypeResponseVariants}} /** {{#notes}} @@ -453,6 +502,34 @@ export class {{classname}} extends runtime.BaseAPI { } {{/useSingleRequestParameter}} {{#useSingleRequestParameter}} + {{#hasContentTypeResponseVariants}} + {{#contentTypeResponseVariants}} + async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept{{#isDefault}}?{{/isDefault}}: '{{mediaType}}' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#returnType}}{{{returnType}}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}>; + {{/contentTypeResponseVariants}} + async {{nickname}}(requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{#contentTypeResponseVariants}}{{{returnType}}}{{^returnType}}void{{/returnType}}{{^-last}} | {{/-last}}{{/contentTypeResponseVariants}}{{#isResponseOptional}} | null | undefined{{/isResponseOptional}}> { + {{! `as any`: from the outside only the overloads of ...Raw are visible, and the loose `accept` + accepted here matches none of them }} + const response = await this.{{nickname}}Raw(requestParameters as any, initOverrides); + {{#isResponseOptional}} + {{! a 2xx response with no body (a 204, typically) has nothing to deserialise: give back null, + the way the unmerged path does }} + switch (response.raw.status) { + {{#responses}} + {{#is2xx}} + case {{code}}: + return {{#dataType}}await response.value(){{/dataType}}{{^dataType}}null{{/dataType}}; + {{/is2xx}} + {{/responses}} + default: + return await response.value(); + } + {{/isResponseOptional}} + {{^isResponseOptional}} + return await response.value(); + {{/isResponseOptional}} + } + {{/hasContentTypeResponseVariants}} + {{^hasContentTypeResponseVariants}} async {{nickname}}({{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{{returnType}}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> { {{#returnType}} const response = await this.{{nickname}}Raw({{#allParams.0}}requestParameters, {{/allParams.0}}initOverrides); @@ -476,6 +553,7 @@ export class {{classname}} extends runtime.BaseAPI { await this.{{nickname}}Raw({{#allParams.0}}requestParameters, {{/allParams.0}}initOverrides); {{/returnType}} } + {{/hasContentTypeResponseVariants}} {{/useSingleRequestParameter}} {{/operation}} @@ -485,36 +563,20 @@ export class {{classname}} extends runtime.BaseAPI { {{#operations}} {{#operation}} +{{^contentTypeMerged}} {{#allParams}} {{#isEnum}} -{{#stringEnums}} -/** - * @export - * @enum {string} - */ -export enum {{operationIdCamelCase}}{{enumName}} { -{{#allowableValues}} - {{#enumVars}} - {{{name}}} = {{{value}}}{{^-last}},{{/-last}} - {{/enumVars}} -{{/allowableValues}} -} -{{/stringEnums}} -{{^stringEnums}} -/** - * @export - */ -export const {{operationIdCamelCase}}{{enumName}} = { -{{#allowableValues}} - {{#enumVars}} - {{{name}}}: {{{value}}}{{^-last}},{{/-last}} - {{/enumVars}} -{{/allowableValues}} -} as const; -export type {{operationIdCamelCase}}{{enumName}} = typeof {{operationIdCamelCase}}{{enumName}}[keyof typeof {{operationIdCamelCase}}{{enumName}}]; -{{/stringEnums}} +{{>apisOperationEnum}} {{/isEnum}} {{/allParams}} +{{/contentTypeMerged}} +{{#contentTypeMerged}} +{{! the request union references every variant's parameters, allParams only holds the surviving + variant's: the merge collected the enum ones across all the variants, deduplicated }} +{{#contentTypeMergedEnumParams}} +{{>apisOperationEnum}} +{{/contentTypeMergedEnumParams}} +{{/contentTypeMerged}} {{/operation}} {{/operations}} {{/hasEnums}} diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apisContentTypeVariantBody.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apisContentTypeVariantBody.mustache new file mode 100644 index 000000000000..4b5e57bfaf22 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apisContentTypeVariantBody.mustache @@ -0,0 +1,34 @@ +{{! Builds the body of one request content-type variant, inside its case of the content-type switch, where + the request union is narrowed to that variant's member. Context: a request variant entry. }} +{{#hasFormParams}} +{{! required form params are guarded here rather than before the switch: they only exist on this + member of the request union, the other content-types do not have them }} +{{#formParams}} +{{#required}} + if (requestParameters['{{paramName}}'] == null) { + throw new runtime.RequiredError( + '{{paramName}}', + 'Required parameter "{{paramName}}" was null or undefined when calling {{nickname}}().' + ); + } +{{/required}} +{{/formParams}} +{{! the lambda leaves the first line alone, so the literal spaces below are what indent it }} + {{#lambda.indented_8_skip_blank}}{{>apisFormParams}}{{/lambda.indented_8_skip_blank}} + {{! a multipart body sets no Content-Type: fetch adds it with the boundary it generates }} + body = formParams; +{{/hasFormParams}} +{{^hasFormParams}} + headerParameters['Content-Type'] = '{{mediaType}}'; +{{#bodyParam}} +{{#required}} + if (requestParameters['{{paramName}}'] == null) { + throw new runtime.RequiredError( + '{{paramName}}', + 'Required parameter "{{paramName}}" was null or undefined when calling {{nickname}}().' + ); + } +{{/required}} +{{/bodyParam}} + body = {{#bodyParam}}{{>apisRequestBodyValue}}{{/bodyParam}}{{^bodyParam}}undefined{{/bodyParam}}; +{{/hasFormParams}} diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apisFormParams.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apisFormParams.mustache new file mode 100644 index 000000000000..0b442d351275 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apisFormParams.mustache @@ -0,0 +1,67 @@ + const consumes: runtime.Consume[] = [ + {{#consumes}} + { contentType: '{{{mediaType}}}' }, + {{/consumes}} + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + {{#formParams}} + {{#isFile}} + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + {{/isFile}} + {{/formParams}} + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + {{#formParams}} + {{#isArray}} + if (requestParameters['{{paramName}}'] != null) { + {{#isCollectionFormatMulti}} + requestParameters['{{paramName}}'].forEach((element) => { + formParams.append('{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}', element as any); + }) + {{/isCollectionFormatMulti}} + {{^isCollectionFormatMulti}} + formParams.append('{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}', {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters['{{paramName}}']{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"])); + {{/isCollectionFormatMulti}} + } + + {{/isArray}} + {{^isArray}} + if (requestParameters['{{paramName}}'] != null) { + {{#isDateTimeType}} + formParams.append('{{baseName}}', (requestParameters['{{paramName}}'] as any).toISOString()); + {{/isDateTimeType}} + {{^isDateTimeType}} + {{#isPrimitiveType}} + formParams.append('{{baseName}}', requestParameters['{{paramName}}'] as any); + {{/isPrimitiveType}} + {{^isPrimitiveType}} + {{#isEnumRef}} + formParams.append('{{baseName}}', requestParameters['{{paramName}}'] as any); + {{/isEnumRef}} + {{^isEnumRef}} + {{^withoutRuntimeChecks}} + {{^isContainer}} + formParams.append('{{baseName}}', new Blob([JSON.stringify({{#isFreeFormObject}}runtime.anyToJSON{{/isFreeFormObject}}{{^isFreeFormObject}}{{{dataType}}}ToJSON{{/isFreeFormObject}}(requestParameters['{{paramName}}']))], { type: "application/json", })); + {{/isContainer}} + {{#isContainer}} + formParams.append('{{baseName}}', new Blob([JSON.stringify(requestParameters['{{paramName}}'])], { type: "application/json", })); + {{/isContainer}} + {{/withoutRuntimeChecks}}{{#withoutRuntimeChecks}} + formParams.append('{{baseName}}', new Blob([JSON.stringify(requestParameters['{{paramName}}'])], { type: "application/json", })); + {{/withoutRuntimeChecks}} + {{/isEnumRef}} + {{/isPrimitiveType}} + {{/isDateTimeType}} + } + + {{/isArray}} + {{/formParams}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apisOperationEnum.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apisOperationEnum.mustache new file mode 100644 index 000000000000..e1641254e521 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apisOperationEnum.mustache @@ -0,0 +1,28 @@ +{{! Declares the enum type of one operation parameter, named after the operation. Context: an enum + parameter, with its operation up the stack. Written flush left, like the enums it replaces. }} +{{#stringEnums}} +/** + * @export + * @enum {string} + */ +export enum {{operationIdCamelCase}}{{enumName}} { +{{#allowableValues}} + {{#enumVars}} + {{{name}}} = {{{value}}}{{^-last}},{{/-last}} + {{/enumVars}} +{{/allowableValues}} +} +{{/stringEnums}} +{{^stringEnums}} +/** + * @export + */ +export const {{operationIdCamelCase}}{{enumName}} = { +{{#allowableValues}} + {{#enumVars}} + {{{name}}}: {{{value}}}{{^-last}},{{/-last}} + {{/enumVars}} +{{/allowableValues}} +} as const; +export type {{operationIdCamelCase}}{{enumName}} = typeof {{operationIdCamelCase}}{{enumName}}[keyof typeof {{operationIdCamelCase}}{{enumName}}]; +{{/stringEnums}} diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apisRequestBodyValue.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apisRequestBodyValue.mustache new file mode 100644 index 000000000000..25a11a08256c --- /dev/null +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apisRequestBodyValue.mustache @@ -0,0 +1,3 @@ +{{! Serialised value of a body parameter. Context: a bodyParam. Used by the content-type variant switch, + which needs the same expression once per request content-type. }} +{{#isContainer}}requestParameters['{{paramName}}']{{^withoutRuntimeChecks}}{{#isArray}}{{#items}}{{^isPrimitiveType}}!.map({{datatype}}ToJSON){{/isPrimitiveType}}{{/items}}{{/isArray}}{{/withoutRuntimeChecks}}{{/isContainer}}{{^isContainer}}{{#isPrimitiveType}}requestParameters['{{paramName}}'] as any{{/isPrimitiveType}}{{^isPrimitiveType}}{{^withoutRuntimeChecks}}{{dataType}}ToJSON(requestParameters['{{paramName}}']){{/withoutRuntimeChecks}}{{#withoutRuntimeChecks}}requestParameters['{{paramName}}']{{/withoutRuntimeChecks}}{{/isPrimitiveType}}{{/isContainer}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/apisResponseVariantValue.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/apisResponseVariantValue.mustache new file mode 100644 index 000000000000..0995bdc9a2b7 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/apisResponseVariantValue.mustache @@ -0,0 +1,42 @@ +{{! Deserialisation of one response content-type variant. Context: the variant operation, so returnType and + friends are the ones the generator resolved natively for that content-type. `as any` is unavoidable: the + implementation signature returns the union of every variant's type, and TypeScript cannot prove which + branch the runtime content-type took. }} +{{#returnType}} +{{#isResponseFile}} + return new runtime.BlobApiResponse(response) as any; +{{/isResponseFile}} +{{^isResponseFile}} +{{#returnTypeIsPrimitive}} +{{#isMap}} + return new runtime.JSONApiResponse(response) as any; +{{/isMap}} +{{#isArray}} + return new runtime.JSONApiResponse(response) as any; +{{/isArray}} +{{#returnSimpleType}} + if (this.isJsonMime(responseContentType)) { + return new runtime.JSONApiResponse<{{returnType}}>(response) as any; + } else { + return new runtime.TextApiResponse(response) as any; + } +{{/returnSimpleType}} +{{/returnTypeIsPrimitive}} +{{^returnTypeIsPrimitive}} +{{#isArray}} + return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{#uniqueItems}}new Set({{/uniqueItems}}jsonValue.map({{returnBaseType}}FromJSON){{/withoutRuntimeChecks}}){{#uniqueItems}}){{/uniqueItems}} as any; +{{/isArray}} +{{^isArray}} +{{#isMap}} + return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => runtime.mapValues(jsonValue, {{returnBaseType}}FromJSON){{/withoutRuntimeChecks}}) as any; +{{/isMap}} +{{^isMap}} + return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{returnBaseType}}FromJSON(jsonValue){{/withoutRuntimeChecks}}) as any; +{{/isMap}} +{{/isArray}} +{{/returnTypeIsPrimitive}} +{{/isResponseFile}} +{{/returnType}} +{{^returnType}} + return new runtime.VoidApiResponse(response) as any; +{{/returnType}} diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache index 235458a2df2f..b1598aacca4a 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache @@ -294,6 +294,21 @@ export const COLLECTION_FORMATS = { export type FetchAPI = WindowOrWorkerGlobalScope['fetch']; +{{#splitOperationsByContentType}} +type AllKeys = T extends unknown ? keyof T : never; +/** + * Makes the members of a union mutually exclusive, by declaring on each of them the keys it does not have + * as `never`. Without it a member is satisfied by a value carrying another member's keys: assignability is + * structural and tolerates surplus properties, and excess property checking never fires against a union + * since a key present in any member counts as known. + * + * `U` distributes while `T` stays the whole union, which is what lets each member see the others' keys. + */ +export type ExclusiveUnion = U extends unknown + ? U & Partial, keyof U>, never>> + : never; + +{{/splitOperationsByContentType}} export type Json = any; export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; export type HTTPHeaders = { [key: string]: string }; diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index dbc777e2ca15..72624f257e5a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -58,6 +58,7 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static org.junit.jupiter.api.Assertions.*; import static org.openapitools.codegen.CodegenConstants.X_ENUM_DESCRIPTIONS; import static org.openapitools.codegen.CodegenConstants.X_ENUM_VARNAMES; @@ -5321,4 +5322,109 @@ private List getNames(List props) { if (props == null) return null; return props.stream().map(v -> v.name).collect(Collectors.toList()); } + + @Test + public void splitOperationsByContentType() { + DefaultCodegen codegen = new DefaultCodegen(); + codegen.setSplitOperationsByContentType(true); + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/issue6708-split-by-content-type.yaml"); + + // POST /reports: request {json -> Report, xml -> ReportXml} x response {json -> Receipt, pdf -> binary} + // is divided into the cartesian product, each variant narrowed to a single content-type on both axes. + Operation post = openAPI.getPaths().get("/reports").getPost(); + List postVariants = codegen.divideOperationsByContentType(openAPI, "/reports", "post", post); + assertThat(postVariants).extracting(Operation::getOperationId) + .containsExactlyInAnyOrder("createReportWithJsonAsJson", "createReportWithJsonAsPdf", + "createReportWithXmlAsJson", "createReportWithXmlAsPdf"); + for (Operation variant : postVariants) { + assertThat(variant.getRequestBody().getContent()).hasSize(1); + assertThat(variant.getResponses().get("200").getContent()).hasSize(1); + } + + // GET /reports/{id}: no request body, response {json -> Report, directlog -> binary} => 2 variants. + Operation get = openAPI.getPaths().get("/reports/{id}").getGet(); + assertThat(codegen.divideOperationsByContentType(openAPI, "/reports/{id}", "get", get)) + .extracting(Operation::getOperationId) + .containsExactlyInAnyOrder("getReportAsJson", "getReportAsDirectlog"); + } + + @Test + public void splitOperationsByContentTypeLeavesUnambiguousOperationsUntouched() { + DefaultCodegen codegen = new DefaultCodegen(); + codegen.setSplitOperationsByContentType(true); + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/petstore.yaml"); + + // petstore has no operation exposing several content-types with different schemas: every operation + // is returned unchanged (as a singleton). + openAPI.getPaths().forEach((path, pathItem) -> + pathItem.readOperationsMap().forEach((method, operation) -> + assertThat(codegen.divideOperationsByContentType(openAPI, path, method.name(), operation)) + .containsExactly(operation))); + } + + @Test + public void splitOperationsByContentTypeUsesTheMethodResponse() { + DefaultCodegen codegen = new DefaultCodegen(); + codegen.setSplitOperationsByContentType(true); + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/issue6708-method-response-target.yaml"); + + // /a: the method response (200, the lowest 2xx) is multi-content -> split by content-type. + Operation getA = openAPI.getPaths().get("/a").getGet(); + assertThat(codegen.divideOperationsByContentType(openAPI, "/a", "get", getA)) + .extracting(Operation::getOperationId) + .containsExactlyInAnyOrder("getAAsJson", "getAAsPdf"); + + // /b: only the non-method response (206) is multi-content; the method response (200) is single, + // so the operation is left untouched - the generator derives the return type from 200 only. + Operation getB = openAPI.getPaths().get("/b").getGet(); + assertThat(codegen.divideOperationsByContentType(openAPI, "/b", "get", getB)).containsExactly(getB); + } + + @Test + public void splitOperationsByContentTypeTagsEveryVariant() { + DefaultCodegen codegen = new DefaultCodegen(); + codegen.setSplitOperationsByContentType(true); + OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/issue6708-split-by-content-type.yaml"); + + Operation post = openAPI.getPaths().get("/reports").getPost(); + List variants = codegen.divideOperationsByContentType(openAPI, "/reports", "post", post); + + assertThat(variants).allSatisfy(variant -> assertThat(variant.getExtensions()) + .containsEntry(CodegenConstants.X_CONTENT_TYPE_VARIANT_GROUP, "createReport")); + + // each variant records the content-type it was narrowed to on each axis and its rank there. Rank 0 is + // the content-type the spec declares first, which is the default one, so a generator merging the + // variants back together never has to rely on the order it happens to receive them in. + assertThat(variants).extracting( + v -> v.getExtensions().get(CodegenConstants.X_CONTENT_TYPE_VARIANT_REQUEST), + v -> v.getExtensions().get(CodegenConstants.X_CONTENT_TYPE_VARIANT_REQUEST_INDEX), + v -> v.getExtensions().get(CodegenConstants.X_CONTENT_TYPE_VARIANT_RESPONSE), + v -> v.getExtensions().get(CodegenConstants.X_CONTENT_TYPE_VARIANT_RESPONSE_INDEX)) + .containsExactlyInAnyOrder( + tuple("application/json", 0, "application/json", 0), + tuple("application/json", 0, "application/pdf", 1), + tuple("application/xml", 1, "application/json", 0), + tuple("application/xml", 1, "application/pdf", 1)); + } + + @Test + public void splitOperationsByContentTypeIsAGlobalOption() { + // the behaviour is language-neutral, so the option is global rather than declared - and documented - + // by every single generator + assertThat(new DefaultCodegen().cliOptions()).extracting(CliOption::getOpt) + .doesNotContain(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE); + + try { + GlobalSettings.setProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true"); + DefaultCodegen codegen = new DefaultCodegen(); + codegen.processOpts(); + assertThat(codegen.splitOperationsByContentType).isTrue(); + } finally { + GlobalSettings.clearProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE); + } + + DefaultCodegen off = new DefaultCodegen(); + off.processOpts(); + assertThat(off.splitOperationsByContentType).isFalse(); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java index 0ba3cd8a5305..3b3c239df02c 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java @@ -67,6 +67,291 @@ public void testOptionalResponseImports() { Assert.assertEquals(operation.isResponseOptional, true); } + @Test + public void testMergesContentTypeVariantsIntoOneMethod() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + Path api = Paths.get(output + "/apis/ReportApi.ts"); + + // the split emitted four operations for POST /reports (2 request x 2 response content-types); + // TypeScript expresses the whole matrix at once, so a single method survives + TestUtils.assertFileNotContains(api, "createReportWithJsonAsJson", "createReportWithXmlAsPdf"); + + // request axis: a union discriminated by contentType, whose value selects the body's type. The + // content-type declared first is the default one, hence optional. + TestUtils.assertFileContains(api, + "runtime.ExclusiveUnion<", + "| { contentType?: 'application/json'; report?: Report; }", + "| { contentType: 'application/xml'; reportXml?: ReportXml; }"); + + // response axis: one overload per content-type, `accept` selecting the return type + TestUtils.assertFileContains(api, + "async createReportRaw(requestParameters: CreateReportRequest & { accept?: 'application/json' }", + "async createReportRaw(requestParameters: CreateReportRequest & { accept: 'application/pdf' }", + "Promise>"); + + // Content-Type is set inside the branch that builds the body, Accept defaults to the response + // content-type declared first, and deserialisation dispatches on what the server actually returned + // rather than on the requested accept + TestUtils.assertFileContains(api, + " headerParameters['Content-Type'] = 'application/xml';", + " headerParameters['Content-Type'] = 'application/json';", + "headerParameters['Accept'] = requestParameters.accept ?? 'application/json';", + "if (responseContentType === 'application/pdf') {"); + } + + @Test + public void testRequestUnionExcludesTheOtherVariantsBodies() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type-required-body.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + Path api = Paths.get(output + "/apis/ReportApi.ts"); + + // runtime.ExclusiveUnion makes the members mutually exclusive, without which an XML body handed to + // the JSON member is silently sent as JSON. Excess property checking never fires against a union - a + // key present in any member counts as known - and the checks that do reject most shapes (weak type + // detection, a missing required property) leave out a member with a required parameter and an + // optional body, as createDraft has. + TestUtils.assertFileContains(api, + "export type CreateDraftRequest = runtime.ExclusiveUnion<", + "| { contentType?: 'application/json'; projectId: string; report?: Report; }", + "| { contentType: 'application/xml'; projectId: string; reportXml?: ReportXml; }"); + + // A required body only exists on one member of the union, so it cannot be guarded before the switch: + // indexing it there would not type-check, and would narrow the union for everything below. + TestUtils.assertFileContains(api, + " if (requestParameters['reportXml'] == null) {", + " if (requestParameters['report'] == null) {"); + + String content = Files.readString(api); + int requestOpts = content.indexOf("async createProjectReportRequestOpts"); + int contentTypeSwitch = content.indexOf("switch (requestParameters.contentType)", requestOpts); + assertThat(content.substring(requestOpts, contentTypeSwitch)) + .as("the body must not be indexed before the content-type is known") + .doesNotContain("requestParameters['report']"); + } + + @Test + public void testEnumParameterFollowsTheMergedOperationName() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type-enum-param.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + // An enum parameter's type is named after its operation, and the enum is declared once. The merge + // renames the operation, so both sides have to end up on the merged name - the request union + // references every variant's parameters, not just the surviving one's. + Path api = Paths.get(output + "/apis/OrderApi.ts"); + TestUtils.assertFileContains(api, + "| { orderBy?: GetOrdersOrderByEnum; }", + "export const GetOrdersOrderByEnum = {"); + } + + @Test + public void testMergedOperationKeepsTheOptionalResponseBehaviour() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type-optional-response.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + // the operation can answer 204 as well as 200: the unmerged path returns null for the bodyless + // status instead of parsing an empty body, and the merged one has to do the same + Path api = Paths.get(output + "/apis/ReportApi.ts"); + TestUtils.assertFileContains(api, + "async getReport(requestParameters: GetReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise;", + "async getReport(requestParameters: GetReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise;", + "switch (response.raw.status) {", + " case 204:", + " return null;"); + } + + @Test + public void testRequiredFormParameterIsOnlyEnforcedOnItsOwnContentType() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type-required-form.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + // `file` is required by the multipart variant only: guarded inside its branch of the content-type + // switch, not before it, where it would throw on a perfectly valid application/json call + Path api = Paths.get(output + "/apis/FilesApi.ts"); + String content = Files.readString(api); + int requestOpts = content.indexOf("async convertRequestOpts"); + int contentTypeSwitch = content.indexOf("switch (requestParameters.contentType)", requestOpts); + assertThat(content.substring(requestOpts, contentTypeSwitch)) + .as("a form parameter must not be enforced before the content-type is known") + .doesNotContain("requestParameters['file'] == null"); + + // multipart was declared first, so its branch is the switch's default case + int defaultCase = content.indexOf("default: {", contentTypeSwitch); + TestUtils.assertFileContains(api, "Required parameter \"file\" was null or undefined"); + assertThat(content.indexOf("requestParameters['file'] == null", defaultCase)) + .as("the guard belongs inside the multipart branch") + .isGreaterThan(defaultCase); + } + + @Test + public void testEnumOfADroppedVariantIsStillDeclared() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type-variant-enum.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + // the `mode` enum only exists on the multipart variant, which the merge drops: the union still + // references its type, so the declaration has to be collected from the dropped variant + Path api = Paths.get(output + "/apis/FilesApi.ts"); + TestUtils.assertFileContains(api, + "mode?: ConvertModeEnum;", + "export const ConvertModeEnum = {"); + + // and declared exactly once, even when several variants share the parameter + String content = Files.readString(api); + String declaration = "export const ConvertModeEnum = {"; + assertThat(content.indexOf(declaration)).isEqualTo(content.lastIndexOf(declaration)); + } + + @Test + public void testMergedOperationNameIsEscapedAgainstImportedModels() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type-name-collision.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + // The spec imports a model named CreateReportRequest into an API whose merged operation is + // createReport. escapeOperationIds ran on the variants' suffixed names and saw no clash, so the + // merge has to replay its escape on the merged name - the same one the unsplit path would emit. + Path api = Paths.get(output + "/apis/ReportApi.ts"); + TestUtils.assertFileContains(api, + "export type CreateReportOperationRequest = runtime.ExclusiveUnion<", + "async createReport(requestParameters: CreateReportOperationRequest"); + TestUtils.assertFileNotContains(api, + "export type CreateReportRequest =", + "export interface CreateReportRequest"); + } + + @Test + public void testMergesFormAndMultipartVariants() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type-form.yaml") + .addGlobalProperty(CodegenConstants.SPLIT_OPERATIONS_BY_CONTENT_TYPE, "true") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + Path api = Paths.get(output + "/apis/FilesApi.ts"); + + // POST /upload has a single, multipart request content-type and two response content-types: only the + // response axis is split, and merging it just adds `accept` to the request object. + TestUtils.assertFileNotContains(api, "uploadAsJson", "uploadAsPdf"); + + // Accept is emitted only where the response axis was divided: elsewhere a caller may have pinned + // one on the Configuration, and an operation-level header silently wins over it. + TestUtils.assertFileContains(api, + "headerParameters['Accept'] = requestParameters.accept ?? 'application/json';"); + TestUtils.assertFileContains(api, + "async upload(requestParameters: UploadRequest & { accept?: 'application/json' }", + "async upload(requestParameters: UploadRequest & { accept: 'application/pdf' }"); + + // POST /convert accepts both application/json and multipart/form-data. A form body is spread over + // individual parameters rather than gathered in one, so the union member carries them as they are + // and the body is assembled inside that content-type's branch. + TestUtils.assertFileNotContains(api, "convertWithJson", "convertWithFormData"); + TestUtils.assertFileContains(api, + "| { contentType?: 'application/json'; receipt?: Receipt; }", + "| { contentType: 'multipart/form-data'; file?: Blob; }", + " case 'multipart/form-data': {", + " body = formParams;"); + + // Content-Type is set per branch rather than once up front: a multipart body must not set it at all, + // fetch adds it with the boundary it generates. + String content = Files.readString(api); + int convert = content.indexOf("async convertRequestOpts"); + int convertEnd = content.indexOf("async convertRaw", convert); + String convertOpts = content.substring(convert, convertEnd); + assertThat(convertOpts).contains("headerParameters['Content-Type'] = 'application/json';"); + assertThat(convertOpts) + .as("a multipart branch must leave Content-Type to fetch") + .doesNotContain("headerParameters['Content-Type'] = 'multipart/form-data';"); + } + + @Test + public void testLeavesOperationsUntouchedWithoutTheGlobalOption() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec("src/test/resources/3_0/issue6708-split-by-content-type.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate().forEach(File::deleteOnExit); + + // opt-in: without the global property nothing is split, so nothing is merged either + Path api = Paths.get(output + "/apis/ReportApi.ts"); + TestUtils.assertFileContains(api, "export interface CreateReportRequest {"); + TestUtils.assertFileNotContains(api, "contentType?: 'application/json'", "accept?: 'application/json'"); + } + @Test public void testModelsWithoutPaths() throws IOException { final String specPath = "src/test/resources/3_1/reusable-components-without-paths.yaml"; diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-method-response-target.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-method-response-target.yaml new file mode 100644 index 000000000000..1e512873e41d --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-method-response-target.yaml @@ -0,0 +1,57 @@ +openapi: 3.0.1 +info: + title: split targets the method response (issue 6708) + version: 1.0.0 +paths: + /a: + get: + operationId: getA + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/A' + application/pdf: + schema: + type: string + format: binary + '206': + description: partial + content: + application/json: + schema: + $ref: '#/components/schemas/A' + /b: + get: + operationId: getB + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/A' + '206': + description: partial + content: + application/json: + schema: + $ref: '#/components/schemas/B' + application/pdf: + schema: + type: string + format: binary +components: + schemas: + A: + type: object + properties: + id: + type: string + B: + type: object + properties: + ref: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-enum-param.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-enum-param.yaml new file mode 100644 index 000000000000..bae400378853 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-enum-param.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.1 +info: + title: enum parameter on a multi content-type operation (issue 6708) + version: 1.0.0 +paths: + /orders: + get: + operationId: getOrders + tags: [order] + parameters: + # inline enum: its generated type is named after the operation, which the merge renames + - name: orderBy + in: query + schema: + type: string + enum: [date, amount] + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/pdf: + schema: + type: string + format: binary +components: + schemas: + Order: + type: object + properties: + reference: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-form.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-form.yaml new file mode 100644 index 000000000000..5904fa6fe0ac --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-form.yaml @@ -0,0 +1,64 @@ +openapi: 3.0.1 +info: + title: content-type split with form bodies + version: 1.0.0 +paths: + # Cas A : body multipart UNIQUE, mais deux content-types de reponse. + # L'axe requete n'est pas decoupe : seul l'axe reponse l'est. + /upload: + post: + operationId: upload + tags: [files] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + name: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + application/pdf: + schema: + type: string + format: binary + # Cas B : deux content-types de requete dont un multipart -> axe requete decoupe. + /convert: + post: + operationId: convert + tags: [files] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' +components: + schemas: + Receipt: + type: object + properties: + number: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-name-collision.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-name-collision.yaml new file mode 100644 index 000000000000..23e60b8e9057 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-name-collision.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.1 +info: + title: merged operation name collides with an imported model (issue 6708) + version: 1.0.0 +paths: + /reports: + post: + operationId: createReport + tags: [report] + requestBody: + content: + # the operation's request interface would be named CreateReportRequest, like this model + application/json: + schema: + $ref: '#/components/schemas/CreateReportRequest' + application/xml: + schema: + $ref: '#/components/schemas/CreateReportXmlRequest' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Report' +components: + schemas: + CreateReportRequest: + type: object + properties: + title: + type: string + CreateReportXmlRequest: + type: object + properties: + titleXml: + type: string + Report: + type: object + properties: + id: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-optional-response.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-optional-response.yaml new file mode 100644 index 000000000000..f64d4a49f8b4 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-optional-response.yaml @@ -0,0 +1,36 @@ +openapi: 3.0.1 +info: + title: optional response on a multi content-type operation (issue 6708) + version: 1.0.0 +paths: + /reports/{id}: + get: + operationId: getReport + tags: [report] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/pdf: + schema: + type: string + format: binary + # a 2xx with no body: the method returns null for it instead of parsing nothing + '204': + description: not ready yet +components: + schemas: + Report: + type: object + properties: + id: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-required-body.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-required-body.yaml new file mode 100644 index 000000000000..b541ead6687f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-required-body.yaml @@ -0,0 +1,75 @@ +openapi: 3.0.1 +info: + title: solidite du discriminant + version: 1.0.0 +paths: + # body optionnel, aucun autre parametre + /reports: + post: + operationId: createReport + tags: [report] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/xml: + schema: + $ref: '#/components/schemas/ReportXml' + responses: + '200': { description: ok, content: { application/json: { schema: { $ref: '#/components/schemas/Receipt' } } } } + # meme chose, mais avec un parametre requis en plus : le membre par defaut n'est plus "weak" + /projects/{projectId}/reports: + post: + operationId: createProjectReport + tags: [report] + parameters: + - name: projectId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/xml: + schema: + $ref: '#/components/schemas/ReportXml' + responses: + '200': { description: ok, content: { application/json: { schema: { $ref: '#/components/schemas/Receipt' } } } } + /projects/{projectId}/drafts: + post: + operationId: createDraft + tags: [report] + parameters: + - name: projectId + in: path + required: true + schema: { type: string } + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/xml: + schema: + $ref: '#/components/schemas/ReportXml' + responses: + '200': { description: ok, content: { application/json: { schema: { $ref: '#/components/schemas/Receipt' } } } } +components: + schemas: + Report: + type: object + properties: + id: { type: string } + name: { type: string } + ReportXml: + type: object + properties: + ref: { type: string } + Receipt: + type: object + properties: + number: { type: string } diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-required-form.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-required-form.yaml new file mode 100644 index 000000000000..19eafeb5e177 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-required-form.yaml @@ -0,0 +1,38 @@ +openapi: 3.0.1 +info: + title: required form parameter on the default content-type variant (issue 6708) + version: 1.0.0 +paths: + /convert: + post: + operationId: convert + tags: [files] + requestBody: + content: + # declared first: this variant is the default one, and its `file` parameter is required - + # but only for callers who actually send multipart + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + application/json: + schema: + $ref: '#/components/schemas/Receipt' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' +components: + schemas: + Receipt: + type: object + properties: + reference: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-sample.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-sample.yaml new file mode 100644 index 000000000000..a4e2d0b0a043 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-sample.yaml @@ -0,0 +1,157 @@ +openapi: 3.0.1 +info: + title: split operations by content-type (issue 6708) + description: > + Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the + option has to handle: a response-only split, a split on both axes, a multipart body, a request + split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every + media type here is one the generator can actually serialise: the option divides operations by + content-type, it does not give the generator encoders it never had. + version: 1.0.0 +paths: + # Response axis only: the request body is untouched, `accept` selects the return type. + /reports/{id}: + get: + operationId: getReport + tags: [report] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/directlog: + schema: + type: string + format: binary + # Both axes: `contentType` selects the body's type, `accept` the return type. + /reports: + post: + operationId: createReport + tags: [report] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/merge-patch+json: + schema: + $ref: '#/components/schemas/ReportPatch' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + application/pdf: + schema: + type: string + format: binary + # A single multipart body, two response content-types: only the response axis is split, and the + # form body is still assembled the way an unsplit operation assembles it. + /upload: + post: + operationId: upload + tags: [files] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + name: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + application/pdf: + schema: + type: string + format: binary + # Request split where one member is multipart: the form body is assembled inside the + # content-type switch, where the request union is narrowed to that member. + /convert: + post: + operationId: convert + tags: [files] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + # Inline enum parameter: its generated type is named after the operation, so the merge has to + # move it onto the merged name rather than leave it on a variant nobody declares. + /orders: + get: + operationId: getOrders + tags: [order] + parameters: + - name: orderBy + in: query + schema: + type: string + enum: [date, amount] + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/pdf: + schema: + type: string + format: binary +components: + schemas: + Report: + type: object + properties: + id: + type: string + name: + type: string + ReportPatch: + type: object + properties: + name: + type: string + Receipt: + type: object + properties: + number: + type: string + Order: + type: object + properties: + reference: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-variant-enum.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-variant-enum.yaml new file mode 100644 index 000000000000..356c620bfcf4 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type-variant-enum.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.1 +info: + title: inline enum on a non-default content-type variant (issue 6708) + version: 1.0.0 +paths: + /convert: + post: + operationId: convert + tags: [files] + requestBody: + content: + # declared first: this variant survives the merge, and it has no `mode` parameter + application/json: + schema: + $ref: '#/components/schemas/Receipt' + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + # inline enum: its type only exists on the dropped variant's parameters + mode: + type: string + enum: [fast, thorough] + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' +components: + schemas: + Receipt: + type: object + properties: + reference: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type.yaml b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type.yaml new file mode 100644 index 000000000000..7b36bb577e41 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue6708-split-by-content-type.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.1 +info: + title: split operations by content-type (issue 6708) + version: 1.0.0 +paths: + /reports/{id}: + get: + operationId: getReport + tags: + - report + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/directlog: + schema: + type: string + format: binary + /reports: + post: + operationId: createReport + tags: + - report + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + application/xml: + schema: + $ref: '#/components/schemas/ReportXml' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + application/pdf: + schema: + type: string + format: binary +components: + schemas: + Report: + type: object + properties: + id: + type: string + name: + type: string + ReportXml: + type: object + properties: + ref: + type: string + Receipt: + type: object + properties: + number: + type: string diff --git a/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml b/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml index b5c5141bdd3d..859141904ec9 100644 --- a/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml +++ b/samples/client/petstore/java/resttemplate-springBoot4-jackson3/pom.xml @@ -269,7 +269,7 @@ UTF-8 - 7.0.8 + 7.0.5 3.1.5 3.0.0 diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.gitignore b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.gitignore new file mode 100644 index 000000000000..149b57654723 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.gitignore @@ -0,0 +1,4 @@ +wwwroot/*.js +node_modules +typings +dist diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.npmignore b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.npmignore new file mode 100644 index 000000000000..42061c01a1c7 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.npmignore @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator-ignore b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator/FILES b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator/FILES new file mode 100644 index 000000000000..83aa803f3d37 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator/FILES @@ -0,0 +1,24 @@ +.gitignore +.npmignore +README.md +docs/FilesApi.md +docs/Order.md +docs/OrderApi.md +docs/Receipt.md +docs/Report.md +docs/ReportApi.md +docs/ReportPatch.md +package.json +src/apis/FilesApi.ts +src/apis/OrderApi.ts +src/apis/ReportApi.ts +src/apis/index.ts +src/index.ts +src/models/Order.ts +src/models/Receipt.ts +src/models/Report.ts +src/models/ReportPatch.ts +src/models/index.ts +src/runtime.ts +tsconfig.esm.json +tsconfig.json diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator/VERSION b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator/VERSION new file mode 100644 index 000000000000..8fc8df61083a --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.25.0-SNAPSHOT diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/README.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/README.md new file mode 100644 index 000000000000..1925622f22c8 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/README.md @@ -0,0 +1,119 @@ +# @openapitools/typescript-fetch-split-by-content-type@1.0.0 + +A TypeScript SDK client for the localhost API. + +## Usage + +First, install the SDK from npm. + +```bash +npm install @openapitools/typescript-fetch-split-by-content-type --save +``` + +Next, try it out. + + +```ts +import { + Configuration, + FilesApi, +} from '@openapitools/typescript-fetch-split-by-content-type'; +import type { ConvertRequest } from '@openapitools/typescript-fetch-split-by-content-type'; + +async function example() { + console.log("🚀 Testing @openapitools/typescript-fetch-split-by-content-type SDK..."); + const api = new FilesApi(); + + const body = { + // Receipt (optional) + receipt: ..., + } satisfies ConvertRequest; + + try { + const data = await api.convert(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + + +## Documentation + +### API Endpoints + +All URIs are relative to *http://localhost* + +| Class | Method | HTTP request | Description +| ----- | ------ | ------------ | ------------- +*FilesApi* | [**convert**](docs/FilesApi.md#convert) | **POST** /convert | +*FilesApi* | [**upload**](docs/FilesApi.md#upload) | **POST** /upload | +*OrderApi* | [**getOrders**](docs/OrderApi.md#getorders) | **GET** /orders | +*ReportApi* | [**createReport**](docs/ReportApi.md#createreport) | **POST** /reports | +*ReportApi* | [**getReport**](docs/ReportApi.md#getreport) | **GET** /reports/{id} | + + +### Models + +- [Order](docs/Order.md) +- [Receipt](docs/Receipt.md) +- [Report](docs/Report.md) +- [ReportPatch](docs/ReportPatch.md) + +### Authorization + +Endpoints do not require authorization. + + +## About + +This TypeScript SDK client supports the [Fetch API](https://fetch.spec.whatwg.org/) +and is automatically generated by the +[OpenAPI Generator](https://openapi-generator.tech) project: + +- API version: `1.0.0` +- Package version: `1.0.0` +- Generator version: `7.25.0-SNAPSHOT` +- Build package: `org.openapitools.codegen.languages.TypeScriptFetchClientCodegen` + +The generated npm module supports the following: + +- Environments + * Node.js + * Webpack + * Browserify +- Language levels + * ES5 - you must have a Promises/A+ library installed + * ES6 +- Module systems + * CommonJS + * ES6 module system + + +## Development + +### Building + +To build the TypeScript source code, you need to have Node.js and npm installed. +After cloning the repository, navigate to the project directory and run: + +```bash +npm install +npm run build +``` + +### Publishing + +Once you've built the package, you can publish it to npm: + +```bash +npm publish +``` + +## License + +[]() diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/FilesApi.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/FilesApi.md new file mode 100644 index 000000000000..b1675500b1ed --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/FilesApi.md @@ -0,0 +1,143 @@ +# FilesApi + +All URIs are relative to *http://localhost* + +| Method | HTTP request | Description | +|------------- | ------------- | -------------| +| [**convert**](FilesApi.md#convert) | **POST** /convert | | +| [**upload**](FilesApi.md#upload) | **POST** /upload | | + + + +## convert + +> Receipt convert(receipt) + + + +### Example + +```ts +import { + Configuration, + FilesApi, +} from '@openapitools/typescript-fetch-split-by-content-type'; +import type { ConvertRequest } from '@openapitools/typescript-fetch-split-by-content-type'; + +async function example() { + console.log("🚀 Testing @openapitools/typescript-fetch-split-by-content-type SDK..."); + const api = new FilesApi(); + + const body = { + // Receipt (optional) + receipt: ..., + } satisfies ConvertRequest; + + try { + const data = await api.convert(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + +### Parameters + + +| Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **receipt** | [Receipt](Receipt.md) | | [Optional] | + +### Return type + +[**Receipt**](Receipt.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: `application/json`, `multipart/form-data` +- **Accept**: `application/json` + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | ok | - | + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + +## upload + +> Receipt upload(file, name) + + + +### Example + +```ts +import { + Configuration, + FilesApi, +} from '@openapitools/typescript-fetch-split-by-content-type'; +import type { UploadRequest } from '@openapitools/typescript-fetch-split-by-content-type'; + +async function example() { + console.log("🚀 Testing @openapitools/typescript-fetch-split-by-content-type SDK..."); + const api = new FilesApi(); + + const body = { + // Blob (optional) + file: BINARY_DATA_HERE, + // string (optional) + name: name_example, + } satisfies UploadRequest; + + try { + const data = await api.upload(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + +### Parameters + + +| Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **file** | `Blob` | | [Optional] [Defaults to `undefined`] | +| **name** | `string` | | [Optional] [Defaults to `undefined`] | + +### Return type + +[**Receipt**](Receipt.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: `multipart/form-data` +- **Accept**: `application/json`, `application/pdf` + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | ok | - | + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Order.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Order.md new file mode 100644 index 000000000000..82efb7a4be8a --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Order.md @@ -0,0 +1,34 @@ + +# Order + + +## Properties + +Name | Type +------------ | ------------- +`reference` | string + +## Example + +```typescript +import type { Order } from '@openapitools/typescript-fetch-split-by-content-type' + +// TODO: Update the object below with actual values +const example = { + "reference": null, +} satisfies Order + +console.log(example) + +// Convert the instance to a JSON string +const exampleJSON: string = JSON.stringify(example) +console.log(exampleJSON) + +// Parse the JSON string back to an object +const exampleParsed = JSON.parse(exampleJSON) as Order +console.log(exampleParsed) +``` + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/OrderApi.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/OrderApi.md new file mode 100644 index 000000000000..fbd39f55998a --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/OrderApi.md @@ -0,0 +1,74 @@ +# OrderApi + +All URIs are relative to *http://localhost* + +| Method | HTTP request | Description | +|------------- | ------------- | -------------| +| [**getOrders**](OrderApi.md#getorders) | **GET** /orders | | + + + +## getOrders + +> Order getOrders(orderBy) + + + +### Example + +```ts +import { + Configuration, + OrderApi, +} from '@openapitools/typescript-fetch-split-by-content-type'; +import type { GetOrdersRequest } from '@openapitools/typescript-fetch-split-by-content-type'; + +async function example() { + console.log("🚀 Testing @openapitools/typescript-fetch-split-by-content-type SDK..."); + const api = new OrderApi(); + + const body = { + // 'date' | 'amount' (optional) + orderBy: orderBy_example, + } satisfies GetOrdersRequest; + + try { + const data = await api.getOrders(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + +### Parameters + + +| Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **orderBy** | `date`, `amount` | | [Optional] [Defaults to `undefined`] [Enum: date, amount] | + +### Return type + +[**Order**](Order.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: `application/json`, `application/pdf` + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | ok | - | + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Receipt.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Receipt.md new file mode 100644 index 000000000000..a154c4bacb59 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Receipt.md @@ -0,0 +1,34 @@ + +# Receipt + + +## Properties + +Name | Type +------------ | ------------- +`number` | string + +## Example + +```typescript +import type { Receipt } from '@openapitools/typescript-fetch-split-by-content-type' + +// TODO: Update the object below with actual values +const example = { + "number": null, +} satisfies Receipt + +console.log(example) + +// Convert the instance to a JSON string +const exampleJSON: string = JSON.stringify(example) +console.log(exampleJSON) + +// Parse the JSON string back to an object +const exampleParsed = JSON.parse(exampleJSON) as Receipt +console.log(exampleParsed) +``` + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Report.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Report.md new file mode 100644 index 000000000000..486e4628c6cc --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/Report.md @@ -0,0 +1,36 @@ + +# Report + + +## Properties + +Name | Type +------------ | ------------- +`id` | string +`name` | string + +## Example + +```typescript +import type { Report } from '@openapitools/typescript-fetch-split-by-content-type' + +// TODO: Update the object below with actual values +const example = { + "id": null, + "name": null, +} satisfies Report + +console.log(example) + +// Convert the instance to a JSON string +const exampleJSON: string = JSON.stringify(example) +console.log(exampleJSON) + +// Parse the JSON string back to an object +const exampleParsed = JSON.parse(exampleJSON) as Report +console.log(exampleParsed) +``` + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/ReportApi.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/ReportApi.md new file mode 100644 index 000000000000..81679a4e4ebc --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/ReportApi.md @@ -0,0 +1,140 @@ +# ReportApi + +All URIs are relative to *http://localhost* + +| Method | HTTP request | Description | +|------------- | ------------- | -------------| +| [**createReport**](ReportApi.md#createreport) | **POST** /reports | | +| [**getReport**](ReportApi.md#getreport) | **GET** /reports/{id} | | + + + +## createReport + +> Receipt createReport(report) + + + +### Example + +```ts +import { + Configuration, + ReportApi, +} from '@openapitools/typescript-fetch-split-by-content-type'; +import type { CreateReportRequest } from '@openapitools/typescript-fetch-split-by-content-type'; + +async function example() { + console.log("🚀 Testing @openapitools/typescript-fetch-split-by-content-type SDK..."); + const api = new ReportApi(); + + const body = { + // Report (optional) + report: ..., + } satisfies CreateReportRequest; + + try { + const data = await api.createReport(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + +### Parameters + + +| Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **report** | [Report](Report.md) | | [Optional] | + +### Return type + +[**Receipt**](Receipt.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: `application/json`, `application/merge-patch+json` +- **Accept**: `application/json`, `application/pdf` + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | ok | - | + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + +## getReport + +> Report getReport(id) + + + +### Example + +```ts +import { + Configuration, + ReportApi, +} from '@openapitools/typescript-fetch-split-by-content-type'; +import type { GetReportRequest } from '@openapitools/typescript-fetch-split-by-content-type'; + +async function example() { + console.log("🚀 Testing @openapitools/typescript-fetch-split-by-content-type SDK..."); + const api = new ReportApi(); + + const body = { + // string + id: id_example, + } satisfies GetReportRequest; + + try { + const data = await api.getReport(body); + console.log(data); + } catch (error) { + console.error(error); + } +} + +// Run the test +example().catch(console.error); +``` + +### Parameters + + +| Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **id** | `string` | | [Defaults to `undefined`] | + +### Return type + +[**Report**](Report.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: `application/json`, `application/directlog` + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +| **200** | ok | - | + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/ReportPatch.md b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/ReportPatch.md new file mode 100644 index 000000000000..53ef5b401a74 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/docs/ReportPatch.md @@ -0,0 +1,34 @@ + +# ReportPatch + + +## Properties + +Name | Type +------------ | ------------- +`name` | string + +## Example + +```typescript +import type { ReportPatch } from '@openapitools/typescript-fetch-split-by-content-type' + +// TODO: Update the object below with actual values +const example = { + "name": null, +} satisfies ReportPatch + +console.log(example) + +// Convert the instance to a JSON string +const exampleJSON: string = JSON.stringify(example) +console.log(exampleJSON) + +// Parse the JSON string back to an object +const exampleParsed = JSON.parse(exampleJSON) as ReportPatch +console.log(exampleParsed) +``` + +[[Back to top]](#) [[Back to API list]](../README.md#api-endpoints) [[Back to Model list]](../README.md#models) [[Back to README]](../README.md) + + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/package.json b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/package.json new file mode 100644 index 000000000000..80640b18336f --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/package.json @@ -0,0 +1,21 @@ +{ + "name": "@openapitools/typescript-fetch-split-by-content-type", + "version": "1.0.0", + "description": "OpenAPI client for @openapitools/typescript-fetch-split-by-content-type", + "author": "OpenAPI-Generator", + "repository": { + "type": "git", + "url": "https://github.com/GIT_USER_ID/GIT_REPO_ID.git" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/esm/index.js", + "sideEffects": false, + "scripts": { + "build": "tsc && tsc -p tsconfig.esm.json", + "prepare": "npm run build" + }, + "devDependencies": { + "typescript": "^4.0 || ^5.0" + } +} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/FilesApi.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/FilesApi.ts new file mode 100644 index 000000000000..0c786c5dcd5c --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/FilesApi.ts @@ -0,0 +1,176 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import * as runtime from '../runtime'; +import { + type Receipt, + ReceiptFromJSON, + ReceiptToJSON, +} from '../models/Receipt'; + +export type ConvertRequest = runtime.ExclusiveUnion< + | { contentType?: 'application/json'; receipt?: Receipt; } + | { contentType: 'multipart/form-data'; file?: Blob; } +>; + +export type UploadRequest = + | { file?: Blob; name?: string; } +; + +/** + * + */ +export class FilesApi extends runtime.BaseAPI { + + /** + * Creates request options for convert without sending the request + */ + async convertRequestOpts(requestParameters: ConvertRequest): Promise { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + + let urlPath = `/convert`; + + let body: any; + switch (requestParameters.contentType) { + case 'multipart/form-data': { + const consumes: runtime.Consume[] = [ + { contentType: 'multipart/form-data' }, + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + if (requestParameters['file'] != null) { + formParams.append('file', requestParameters['file'] as any); + } + + + body = formParams; + break; + } + default: { + headerParameters['Content-Type'] = 'application/json'; + body = ReceiptToJSON(requestParameters['receipt']); + } + } + + return { + path: urlPath, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: body, + }; + } + + /** + */ + async convertRaw(requestParameters: ConvertRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.convertRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ReceiptFromJSON(jsonValue)); + } + + /** + */ + async convert(requestParameters: ConvertRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.convertRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * Creates request options for upload without sending the request + */ + async uploadRequestOpts(requestParameters: UploadRequest & { accept?: string }): Promise { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Accept'] = requestParameters.accept ?? 'application/json'; + + const consumes: runtime.Consume[] = [ + { contentType: 'multipart/form-data' }, + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + if (requestParameters['file'] != null) { + formParams.append('file', requestParameters['file'] as any); + } + + if (requestParameters['name'] != null) { + formParams.append('name', requestParameters['name'] as any); + } + + + let urlPath = `/upload`; + + return { + path: urlPath, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: formParams, + }; + } + + /** + */ + async uploadRaw(requestParameters: UploadRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async uploadRaw(requestParameters: UploadRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async uploadRaw(requestParameters: UploadRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.uploadRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + const responseContentType = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + if (responseContentType === 'application/pdf') { + return new runtime.BlobApiResponse(response) as any; + } else { + return new runtime.JSONApiResponse(response, (jsonValue) => ReceiptFromJSON(jsonValue)) as any; + } + } + + /** + */ + async upload(requestParameters: UploadRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async upload(requestParameters: UploadRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async upload(requestParameters: UploadRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.uploadRaw(requestParameters as any, initOverrides); + return await response.value(); + } + +} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/OrderApi.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/OrderApi.ts new file mode 100644 index 000000000000..aed1acf1f6b1 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/OrderApi.ts @@ -0,0 +1,90 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import * as runtime from '../runtime'; +import { + type Order, + OrderFromJSON, + OrderToJSON, +} from '../models/Order'; + +export type GetOrdersRequest = + | { orderBy?: GetOrdersOrderByEnum; } +; + +/** + * + */ +export class OrderApi extends runtime.BaseAPI { + + /** + * Creates request options for getOrders without sending the request + */ + async getOrdersRequestOpts(requestParameters: GetOrdersRequest & { accept?: string }): Promise { + const queryParameters: any = {}; + + if (requestParameters['orderBy'] != null) { + queryParameters['orderBy'] = requestParameters['orderBy']; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Accept'] = requestParameters.accept ?? 'application/json'; + + + let urlPath = `/orders`; + + return { + path: urlPath, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }; + } + + /** + */ + async getOrdersRaw(requestParameters: GetOrdersRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async getOrdersRaw(requestParameters: GetOrdersRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async getOrdersRaw(requestParameters: GetOrdersRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.getOrdersRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + const responseContentType = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + if (responseContentType === 'application/pdf') { + return new runtime.BlobApiResponse(response) as any; + } else { + return new runtime.JSONApiResponse(response, (jsonValue) => OrderFromJSON(jsonValue)) as any; + } + } + + /** + */ + async getOrders(requestParameters: GetOrdersRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async getOrders(requestParameters: GetOrdersRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async getOrders(requestParameters: GetOrdersRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getOrdersRaw(requestParameters as any, initOverrides); + return await response.value(); + } + +} + +/** + * @export + */ +export const GetOrdersOrderByEnum = { + Date: 'date', + Amount: 'amount' +} as const; +export type GetOrdersOrderByEnum = typeof GetOrdersOrderByEnum[keyof typeof GetOrdersOrderByEnum]; diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/ReportApi.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/ReportApi.ts new file mode 100644 index 000000000000..0cb4e28ae8b4 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/ReportApi.ts @@ -0,0 +1,160 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import * as runtime from '../runtime'; +import { + type Receipt, + ReceiptFromJSON, + ReceiptToJSON, +} from '../models/Receipt'; +import { + type Report, + ReportFromJSON, + ReportToJSON, +} from '../models/Report'; +import { + type ReportPatch, + ReportPatchFromJSON, + ReportPatchToJSON, +} from '../models/ReportPatch'; + +export type CreateReportRequest = runtime.ExclusiveUnion< + | { contentType?: 'application/json'; report?: Report; } + | { contentType: 'application/merge-patch+json'; reportPatch?: ReportPatch; } +>; + +export type GetReportRequest = + | { id: string; } +; + +/** + * + */ +export class ReportApi extends runtime.BaseAPI { + + /** + * Creates request options for createReport without sending the request + */ + async createReportRequestOpts(requestParameters: CreateReportRequest & { accept?: string }): Promise { + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Accept'] = requestParameters.accept ?? 'application/json'; + + + let urlPath = `/reports`; + + let body: any; + switch (requestParameters.contentType) { + case 'application/merge-patch+json': { + headerParameters['Content-Type'] = 'application/merge-patch+json'; + body = ReportPatchToJSON(requestParameters['reportPatch']); + break; + } + default: { + headerParameters['Content-Type'] = 'application/json'; + body = ReportToJSON(requestParameters['report']); + } + } + + return { + path: urlPath, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: body, + }; + } + + /** + */ + async createReportRaw(requestParameters: CreateReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async createReportRaw(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async createReportRaw(requestParameters: CreateReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.createReportRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + const responseContentType = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + if (responseContentType === 'application/pdf') { + return new runtime.BlobApiResponse(response) as any; + } else { + return new runtime.JSONApiResponse(response, (jsonValue) => ReceiptFromJSON(jsonValue)) as any; + } + } + + /** + */ + async createReport(requestParameters: CreateReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async createReport(requestParameters: CreateReportRequest & { accept: 'application/pdf' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async createReport(requestParameters: CreateReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.createReportRaw(requestParameters as any, initOverrides); + return await response.value(); + } + + /** + * Creates request options for getReport without sending the request + */ + async getReportRequestOpts(requestParameters: GetReportRequest & { accept?: string }): Promise { + if (requestParameters['id'] == null) { + throw new runtime.RequiredError( + 'id', + 'Required parameter "id" was null or undefined when calling getReport().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Accept'] = requestParameters.accept ?? 'application/json'; + + + let urlPath = `/reports/{id}`; + urlPath = urlPath.replace('{id}', encodeURIComponent(String(requestParameters['id']))); + + return { + path: urlPath, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }; + } + + /** + */ + async getReportRaw(requestParameters: GetReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async getReportRaw(requestParameters: GetReportRequest & { accept: 'application/directlog' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise>; + async getReportRaw(requestParameters: GetReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const requestOptions = await this.getReportRequestOpts(requestParameters); + const response = await this.request(requestOptions, initOverrides); + + const responseContentType = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + if (responseContentType === 'application/directlog') { + return new runtime.BlobApiResponse(response) as any; + } else { + return new runtime.JSONApiResponse(response, (jsonValue) => ReportFromJSON(jsonValue)) as any; + } + } + + /** + */ + async getReport(requestParameters: GetReportRequest & { accept?: 'application/json' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async getReport(requestParameters: GetReportRequest & { accept: 'application/directlog' }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise; + async getReport(requestParameters: GetReportRequest & { accept?: string }, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getReportRaw(requestParameters as any, initOverrides); + return await response.value(); + } + +} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/index.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/index.ts new file mode 100644 index 000000000000..b374c4d14a91 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/apis/index.ts @@ -0,0 +1,5 @@ +/* tslint:disable */ +/* eslint-disable */ +export * from './FilesApi'; +export * from './OrderApi'; +export * from './ReportApi'; diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/index.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/index.ts new file mode 100644 index 000000000000..bebe8bbbe206 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/index.ts @@ -0,0 +1,5 @@ +/* tslint:disable */ +/* eslint-disable */ +export * from './runtime'; +export * from './apis/index'; +export * from './models/index'; diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Order.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Order.ts new file mode 100644 index 000000000000..1ae006797d9e --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Order.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface Order + */ +export interface Order { + /** + * + */ + reference?: string; +} + +/** + * Check if a given object implements the Order interface. + */ +export function instanceOfOrder(value: object): value is Order { + return true; +} + +export function OrderFromJSON(json: any): Order { + return OrderFromJSONTyped(json, false); +} + +export function OrderFromJSONTyped(json: any, ignoreDiscriminator: boolean): Order { + if (json == null) { + return json; + } + return { + + 'reference': json['reference'] == null ? undefined : json['reference'], + }; +} + +export function OrderToJSON(json: any): Order { + return OrderToJSONTyped(json, false); +} + +export function OrderToJSONTyped(value?: Order | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'reference': value['reference'], + }; +} + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Receipt.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Receipt.ts new file mode 100644 index 000000000000..97f9bbb5003e --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Receipt.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface Receipt + */ +export interface Receipt { + /** + * + */ + number?: string; +} + +/** + * Check if a given object implements the Receipt interface. + */ +export function instanceOfReceipt(value: object): value is Receipt { + return true; +} + +export function ReceiptFromJSON(json: any): Receipt { + return ReceiptFromJSONTyped(json, false); +} + +export function ReceiptFromJSONTyped(json: any, ignoreDiscriminator: boolean): Receipt { + if (json == null) { + return json; + } + return { + + 'number': json['number'] == null ? undefined : json['number'], + }; +} + +export function ReceiptToJSON(json: any): Receipt { + return ReceiptToJSONTyped(json, false); +} + +export function ReceiptToJSONTyped(value?: Receipt | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'number': value['number'], + }; +} + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Report.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Report.ts new file mode 100644 index 000000000000..1bf0d9c1d2a1 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/Report.ts @@ -0,0 +1,69 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface Report + */ +export interface Report { + /** + * + */ + id?: string; + /** + * + */ + name?: string; +} + +/** + * Check if a given object implements the Report interface. + */ +export function instanceOfReport(value: object): value is Report { + return true; +} + +export function ReportFromJSON(json: any): Report { + return ReportFromJSONTyped(json, false); +} + +export function ReportFromJSONTyped(json: any, ignoreDiscriminator: boolean): Report { + if (json == null) { + return json; + } + return { + + 'id': json['id'] == null ? undefined : json['id'], + 'name': json['name'] == null ? undefined : json['name'], + }; +} + +export function ReportToJSON(json: any): Report { + return ReportToJSONTyped(json, false); +} + +export function ReportToJSONTyped(value?: Report | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'id': value['id'], + 'name': value['name'], + }; +} + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/ReportPatch.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/ReportPatch.ts new file mode 100644 index 000000000000..d89ed019828b --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/ReportPatch.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from '../runtime'; +/** + * + * @export + * @interface ReportPatch + */ +export interface ReportPatch { + /** + * + */ + name?: string; +} + +/** + * Check if a given object implements the ReportPatch interface. + */ +export function instanceOfReportPatch(value: object): value is ReportPatch { + return true; +} + +export function ReportPatchFromJSON(json: any): ReportPatch { + return ReportPatchFromJSONTyped(json, false); +} + +export function ReportPatchFromJSONTyped(json: any, ignoreDiscriminator: boolean): ReportPatch { + if (json == null) { + return json; + } + return { + + 'name': json['name'] == null ? undefined : json['name'], + }; +} + +export function ReportPatchToJSON(json: any): ReportPatch { + return ReportPatchToJSONTyped(json, false); +} + +export function ReportPatchToJSONTyped(value?: ReportPatch | null, ignoreDiscriminator: boolean = false): any { + if (value == null) { + return value; + } + + return { + + 'name': value['name'], + }; +} + diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/index.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/index.ts new file mode 100644 index 000000000000..c89a7699d415 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/models/index.ts @@ -0,0 +1,6 @@ +/* tslint:disable */ +/* eslint-disable */ +export * from './Order'; +export * from './Receipt'; +export * from './Report'; +export * from './ReportPatch'; diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts new file mode 100644 index 000000000000..f37232b5ec76 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/src/runtime.ts @@ -0,0 +1,467 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * split operations by content-type (issue 6708) + * Spec behind the typescript-fetch `split-by-content-type` sample. It gathers the shapes the option has to handle: a response-only split, a split on both axes, a multipart body, a request split that mixes JSON and multipart, and an enum parameter carried by a split operation. Every media type here is one the generator can actually serialise: the option divides operations by content-type, it does not give the generator encoders it never had. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +export const BASE_PATH = "http://localhost".replace(/\/+$/, ""); + +export interface ConfigurationParameters { + basePath?: string; // override base path + fetchApi?: FetchAPI; // override for fetch implementation + middleware?: Middleware[]; // middleware to apply before/after fetch requests + queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings + username?: string; // parameter for basic security + password?: string; // parameter for basic security + apiKey?: string | Promise | ((name: string) => string | Promise); // parameter for apiKey security + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + headers?: HTTPHeaders; //header params we want to use on every request + credentials?: RequestCredentials; //value for the credentials param we want to use on each request +} + +export class Configuration { + constructor(private configuration: ConfigurationParameters = {}) {} + + set config(configuration: Configuration) { + this.configuration = configuration; + } + + get basePath(): string { + return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH; + } + + get fetchApi(): FetchAPI | undefined { + return this.configuration.fetchApi; + } + + get middleware(): Middleware[] { + return this.configuration.middleware || []; + } + + get queryParamsStringify(): (params: HTTPQuery) => string { + return this.configuration.queryParamsStringify || querystring; + } + + get username(): string | undefined { + return this.configuration.username; + } + + get password(): string | undefined { + return this.configuration.password; + } + + get apiKey(): ((name: string) => string | Promise) | undefined { + const apiKey = this.configuration.apiKey; + if (apiKey) { + return typeof apiKey === 'function' ? apiKey : () => apiKey; + } + return undefined; + } + + get accessToken(): ((name?: string, scopes?: string[]) => string | Promise) | undefined { + const accessToken = this.configuration.accessToken; + if (accessToken) { + return typeof accessToken === 'function' ? accessToken : async () => accessToken; + } + return undefined; + } + + get headers(): HTTPHeaders | undefined { + return this.configuration.headers; + } + + get credentials(): RequestCredentials | undefined { + return this.configuration.credentials; + } +} + +export const DefaultConfig = new Configuration(); + +/** + * This is the base class for all generated API classes. + */ +export class BaseAPI { + + private static readonly jsonRegex = /^(:?application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(:?;.*)?$/i; + private middleware: Middleware[]; + + constructor(protected configuration = DefaultConfig) { + this.middleware = configuration.middleware; + } + + withMiddleware(this: T, ...middlewares: Middleware[]) { + const next = this.clone(); + next.middleware = next.middleware.concat(...middlewares); + return next; + } + + withPreMiddleware(this: T, ...preMiddlewares: Array) { + const middlewares = preMiddlewares.map((pre) => ({ pre })); + return this.withMiddleware(...middlewares); + } + + withPostMiddleware(this: T, ...postMiddlewares: Array) { + const middlewares = postMiddlewares.map((post) => ({ post })); + return this.withMiddleware(...middlewares); + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + protected isJsonMime(mime: string | null | undefined): boolean { + if (!mime) { + return false; + } + return BaseAPI.jsonRegex.test(mime); + } + + protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise { + const { url, init } = await this.createFetchParams(context, initOverrides); + const response = await this.fetchApi(url, init); + if (response && (response.status >= 200 && response.status < 300)) { + return response; + } + throw new ResponseError(response, 'Response returned an error code'); + } + + private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) { + let url = this.configuration.basePath + context.path; + if (context.query !== undefined && Object.keys(context.query).length !== 0) { + // only add the querystring to the URL if there are query parameters. + // this is done to avoid urls ending with a "?" character which buggy webservers + // do not handle correctly sometimes. + url += '?' + this.configuration.queryParamsStringify(context.query); + } + + const headers = Object.assign({}, this.configuration.headers, context.headers); + Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {}); + + const initOverrideFn = + typeof initOverrides === "function" + ? initOverrides + : async () => initOverrides; + + const initParams = { + method: context.method, + headers, + body: context.body, + credentials: this.configuration.credentials, + }; + + const overriddenInit: RequestInit = { + ...initParams, + ...(await initOverrideFn({ + init: initParams, + context, + })) + }; + + let body: any; + if (isFormData(overriddenInit.body) + || (overriddenInit.body instanceof URLSearchParams) + || isBlob(overriddenInit.body)) { + body = overriddenInit.body; + } else if (this.isJsonMime(headers['Content-Type'])) { + body = JSON.stringify(overriddenInit.body); + } else { + body = overriddenInit.body; + } + + const init: RequestInit = { + ...overriddenInit, + body + }; + + return { url, init }; + } + + private fetchApi = async (url: string, init: RequestInit) => { + let fetchParams = { url, init }; + for (const middleware of this.middleware) { + if (middleware.pre) { + fetchParams = await middleware.pre({ + fetch: this.fetchApi, + ...fetchParams, + }) || fetchParams; + } + } + let response: Response | undefined = undefined; + try { + response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init); + } catch (e) { + for (const middleware of this.middleware) { + if (middleware.onError) { + response = await middleware.onError({ + fetch: this.fetchApi, + url: fetchParams.url, + init: fetchParams.init, + error: e, + response: response ? response.clone() : undefined, + }) || response; + } + } + if (response === undefined) { + if (e instanceof Error) { + throw new FetchError(e, 'The request failed and the interceptors did not return an alternative response'); + } else { + throw e; + } + } + } + for (const middleware of this.middleware) { + if (middleware.post) { + response = await middleware.post({ + fetch: this.fetchApi, + url: fetchParams.url, + init: fetchParams.init, + response: response.clone(), + }) || response; + } + } + return response; + } + + /** + * Create a shallow clone of `this` by constructing a new instance + * and then shallow cloning data members. + */ + private clone(this: T): T { + const constructor = this.constructor as any; + const next = new constructor(this.configuration); + next.middleware = this.middleware.slice(); + return next; + } +}; + +function isBlob(value: any): value is Blob { + return typeof Blob !== 'undefined' && value instanceof Blob; +} + +function isFormData(value: any): value is FormData { + return typeof FormData !== "undefined" && value instanceof FormData; +} + +export class ResponseError extends Error { + override name: "ResponseError" = "ResponseError"; + constructor(public response: Response, msg?: string) { + super(msg); + + // restore prototype chain + const actualProto = new.target.prototype; + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } + } +} + +export class FetchError extends Error { + override name: "FetchError" = "FetchError"; + constructor(public cause: Error, msg?: string) { + super(msg); + + // restore prototype chain + const actualProto = new.target.prototype; + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } + } +} + +export class RequiredError extends Error { + override name: "RequiredError" = "RequiredError"; + constructor(public field: string, msg?: string) { + super(msg); + + // restore prototype chain + const actualProto = new.target.prototype; + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } + } +} + +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +export type FetchAPI = WindowOrWorkerGlobalScope['fetch']; + +type AllKeys = T extends unknown ? keyof T : never; +/** + * Makes the members of a union mutually exclusive, by declaring on each of them the keys it does not have + * as `never`. Without it a member is satisfied by a value carrying another member's keys: assignability is + * structural and tolerates surplus properties, and excess property checking never fires against a union + * since a key present in any member counts as known. + * + * `U` distributes while `T` stays the whole union, which is what lets each member see the others' keys. + */ +export type ExclusiveUnion = U extends unknown + ? U & Partial, keyof U>, never>> + : never; + +export type Json = any; +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type HTTPHeaders = { [key: string]: string }; +export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; +export type HTTPBody = Json | FormData | URLSearchParams; +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; +export type ModelPropertyNaming = 'camelCase' | 'snake_case' | 'PascalCase' | 'original'; + +export type InitOverrideFunction = (requestContext: { init: HTTPRequestInit, context: RequestOpts }) => Promise + +export interface FetchParams { + url: string; + init: RequestInit; +} + +export interface RequestOpts { + path: string; + method: HTTPMethod; + headers: HTTPHeaders; + query?: HTTPQuery; + body?: HTTPBody; +} + +export function querystring(params: HTTPQuery, prefix: string = ''): string { + return Object.keys(params) + .map(key => querystringSingleKey(key, params[key], prefix)) + .filter(part => part.length > 0) + .join('&'); +} + +function querystringSingleKey(key: string, value: string | number | null | undefined | boolean | Array | Set | HTTPQuery, keyPrefix: string = ''): string { + const fullKey = keyPrefix + (keyPrefix.length ? `[${key}]` : key); + if (value instanceof Array) { + const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue))) + .join(`&${encodeURIComponent(fullKey)}=`); + return `${encodeURIComponent(fullKey)}=${multiValue}`; + } + if (value instanceof Set) { + const valueAsArray = Array.from(value); + return querystringSingleKey(key, valueAsArray, keyPrefix); + } + if (value instanceof Date) { + return `${encodeURIComponent(fullKey)}=${encodeURIComponent(value.toISOString())}`; + } + if (value instanceof Object) { + return querystring(value as HTTPQuery, fullKey); + } + return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`; +} + +export function exists(json: any, key: string) { + const value = json[key]; + return value !== null && value !== undefined; +} + +export function mapValues(data: any, fn: (item: any) => any) { + const result: { [key: string]: any } = {}; + for (const key of Object.keys(data)) { + result[key] = fn(data[key]); + } + return result; +} + +// Pass-through serializer for `any`-typed properties in form data. See #1877. +export function anyToJSON(value: any): any { + return value; +} + +export function canConsumeForm(consumes: Consume[]): boolean { + for (const consume of consumes) { + if (consume.contentType?.startsWith('multipart/form-data') == true) { + return true; + } + } + return false; +} + +export interface Consume { + contentType: string; +} + +export interface RequestContext { + fetch: FetchAPI; + url: string; + init: RequestInit; +} + +export interface ResponseContext { + fetch: FetchAPI; + url: string; + init: RequestInit; + response: Response; +} + +export interface ErrorContext { + fetch: FetchAPI; + url: string; + init: RequestInit; + error: unknown; + response?: Response; +} + +export interface Middleware { + pre?(context: RequestContext): Promise; + post?(context: ResponseContext): Promise; + onError?(context: ErrorContext): Promise; +} + +export interface ApiResponse { + raw: Response; + value(): Promise; +} + +export interface ResponseTransformer { + (json: any): T; +} + +export class JSONApiResponse { + constructor(public raw: Response, private transformer: ResponseTransformer = (jsonValue: any) => jsonValue) {} + + async value(): Promise { + return this.transformer(await this.raw.json()); + } +} + +export class VoidApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return undefined; + } +} + +export class BlobApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return await this.raw.blob(); + }; +} + +export class TextApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return await this.raw.text(); + }; +} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/tsconfig.esm.json b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/tsconfig.esm.json new file mode 100644 index 000000000000..2c0331cce040 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "outDir": "dist/esm" + } +} diff --git a/samples/client/petstore/typescript-fetch/builds/split-by-content-type/tsconfig.json b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/tsconfig.json new file mode 100644 index 000000000000..f1d5adffdbf7 --- /dev/null +++ b/samples/client/petstore/typescript-fetch/builds/split-by-content-type/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declaration": true, + "target": "es6", + "module": "commonjs", + "outDir": "dist", + "rootDir": "src", + "typeRoots": [ + "node_modules/@types" + ] + }, + "exclude": [ + "dist", + "node_modules" + ] +}