diff --git a/bin/configs/swift-combine-petstore-new.yaml b/bin/configs/swift-combine-petstore-new.yaml new file mode 100644 index 000000000000..5349e056a5b8 --- /dev/null +++ b/bin/configs/swift-combine-petstore-new.yaml @@ -0,0 +1,7 @@ +generatorName: swift-combine +outputDir: samples/client/petstore/swift-combine/client +inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml +templateDir: modules/openapi-generator/src/main/resources/swift-combine +additionalProperties: + hideGenerationTimestamp: "true" + projectName: "PetstoreOpenAPI" diff --git a/bitrise.yml b/bitrise.yml index 0f8b81e2450d..290f77635082 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -27,6 +27,15 @@ workflows: set -e ./samples/client/petstore/swift5/swift5_test_all.sh + - script@1.2.0: + title: Run swift-combine tests + inputs: + - content: | + #!/usr/bin/env bash + + set -e + + ./samples/client/petstore/swift-combine/swift-combine_test_all.sh meta: bitrise.io: diff --git a/docs/generators.md b/docs/generators.md index be5dbba7348f..28bed17d3d06 100644 --- a/docs/generators.md +++ b/docs/generators.md @@ -59,6 +59,7 @@ The following generators are available: * [scala-gatling](generators/scala-gatling.md) * [scala-sttp (beta)](generators/scala-sttp.md) * [scalaz](generators/scalaz.md) +* [swift-combine](generators/swift-combine.md) * [swift5](generators/swift5.md) * [typescript (experimental)](generators/typescript.md) * [typescript-angular](generators/typescript-angular.md) diff --git a/docs/generators/swift-combine.md b/docs/generators/swift-combine.md new file mode 100644 index 000000000000..69c993b8fc78 --- /dev/null +++ b/docs/generators/swift-combine.md @@ -0,0 +1,328 @@ +--- +title: Documentation for the swift-combine Generator +--- + +## METADATA + +| Property | Value | Notes | +| -------- | ----- | ----- | +| generator name | swift-combine | pass this to the generate command after -g | +| generator stability | STABLE | | +| generator type | CLIENT | | +| generator language | Swift | | +| generator default templating engine | mustache | | +| helpTxt | Generates a Swift Combine client library. | | + +## CONFIG OPTIONS +These options may be applied as additional-properties (cli) or configOptions (plugins). Refer to [configuration docs](https://openapi-generator.tech/docs/configuration) for more details. + +| Option | Description | Values | Default | +| ------ | ----------- | ------ | ------- | +|allowUnicodeIdentifiers|boolean, toggles whether unicode identifiers are allowed in names or not, default is false| |false| +|apiNamePrefix|Prefix that will be appended to all API names ('tags'). Default: empty string. e.g. Pet => Pet.| |null| +|disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|
**false**
The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.
**true**
Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.
|true| +|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true| +|enumUnknownDefaultCase|If the server adds new enum cases, that are unknown by an old spec/client, the client will fail to parse the network response.With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the server sends an enum case that is not known by the client/spec, they can safely fallback to this case.|
**false**
No changes to the enum's are made, this is the default option.
**true**
With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the enum case sent by the server is not known by the client/spec, can safely be decoded to this case.
|false| +|legacyDiscriminatorBehavior|Set to false for generators with better support for discriminators. (Python, Java, Go, PowerShell, C# have this enabled by default).|
**true**
The mapping in the discriminator includes descendent schemas that allOf inherit from self and the discriminator mapping schemas in the OAS document.
**false**
The mapping in the discriminator includes any descendent schemas that allOf inherit from self, any oneOf schemas, any anyOf schemas, any x-discriminator-values, and the discriminator mapping schemas in the OAS document AND Codegen validates that oneOf and anyOf schemas contain the required discriminator and throws an error if the discriminator is missing.
|true| +|mapFileBinaryToData|Map File and Binary to Data (default: true)| |true| +|prependFormOrBodyParameters|Add form or body parameters to the beginning of the parameter list.| |false| +|projectName|Project name in Xcode| |null| +|sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |true| +|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true| + +## IMPORT MAPPING + +| Type/Alias | Imports | +| ---------- | ------- | + + +## INSTANTIATION TYPES + +| Type/Alias | Instantiated By | +| ---------- | --------------- | +|array|Array| +|list|Array| + + +## LANGUAGE PRIMITIVES + + + +## RESERVED WORDS + + + +## FEATURE SET + + +### Client Modification Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|BasePath|✗|ToolingExtension +|Authorizations|✗|ToolingExtension +|UserAgent|✗|ToolingExtension +|MockServer|✗|ToolingExtension + +### Data Type Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Custom|✗|OAS2,OAS3 +|Int32|✓|OAS2,OAS3 +|Int64|✓|OAS2,OAS3 +|Float|✓|OAS2,OAS3 +|Double|✓|OAS2,OAS3 +|Decimal|✓|ToolingExtension +|String|✓|OAS2,OAS3 +|Byte|✓|OAS2,OAS3 +|Binary|✓|OAS2,OAS3 +|Boolean|✓|OAS2,OAS3 +|Date|✓|OAS2,OAS3 +|DateTime|✓|OAS2,OAS3 +|Password|✓|OAS2,OAS3 +|File|✓|OAS2 +|Uuid|✗| +|Array|✓|OAS2,OAS3 +|Null|✗|OAS3 +|AnyType|✗|OAS2,OAS3 +|Object|✓|OAS2,OAS3 +|Maps|✓|ToolingExtension +|CollectionFormat|✓|OAS2 +|CollectionFormatMulti|✓|OAS2 +|Enum|✓|OAS2,OAS3 +|ArrayOfEnum|✓|ToolingExtension +|ArrayOfModel|✓|ToolingExtension +|ArrayOfCollectionOfPrimitives|✓|ToolingExtension +|ArrayOfCollectionOfModel|✓|ToolingExtension +|ArrayOfCollectionOfEnum|✓|ToolingExtension +|MapOfEnum|✓|ToolingExtension +|MapOfModel|✓|ToolingExtension +|MapOfCollectionOfPrimitives|✓|ToolingExtension +|MapOfCollectionOfModel|✓|ToolingExtension +|MapOfCollectionOfEnum|✓|ToolingExtension + +### Documentation Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Readme|✗|ToolingExtension +|Model|✓|ToolingExtension +|Api|✓|ToolingExtension + +### Global Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Host|✓|OAS2,OAS3 +|BasePath|✓|OAS2,OAS3 +|Info|✓|OAS2,OAS3 +|Schemes|✗|OAS2,OAS3 +|PartialSchemes|✓|OAS2,OAS3 +|Consumes|✓|OAS2 +|Produces|✓|OAS2 +|ExternalDocumentation|✓|OAS2,OAS3 +|Examples|✓|OAS2,OAS3 +|XMLStructureDefinitions|✗|OAS2,OAS3 +|MultiServer|✗|OAS3 +|ParameterizedServer|✗|OAS3 +|ParameterStyling|✗|OAS3 +|Callbacks|✓|OAS3 +|LinkObjects|✗|OAS3 + +### Parameter Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Path|✓|OAS2,OAS3 +|Query|✓|OAS2,OAS3 +|Header|✓|OAS2,OAS3 +|Body|✓|OAS2 +|FormUnencoded|✓|OAS2 +|FormMultipart|✓|OAS2 +|Cookie|✓|OAS3 + +### Schema Support Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Simple|✓|OAS2,OAS3 +|Composite|✓|OAS2,OAS3 +|Polymorphism|✓|OAS2,OAS3 +|Union|✗|OAS3 +|allOf|✗|OAS2,OAS3 +|anyOf|✗|OAS3 +|oneOf|✗|OAS3 +|not|✗|OAS3 + +### Security Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|BasicAuth|✓|OAS2,OAS3 +|ApiKey|✓|OAS2,OAS3 +|OpenIDConnect|✗|OAS3 +|BearerToken|✓|OAS3 +|OAuth2_Implicit|✓|OAS2,OAS3 +|OAuth2_Password|✓|OAS2,OAS3 +|OAuth2_ClientCredentials|✓|OAS2,OAS3 +|OAuth2_AuthorizationCode|✓|OAS2,OAS3 +|SignatureAuth|✗|OAS3 + +### Wire Format Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|JSON|✓|OAS2,OAS3 +|XML|✓|OAS2,OAS3 +|PROTOBUF|✗|ToolingExtension +|Custom|✗|OAS2,OAS3 diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SwiftCombineClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SwiftCombineClientCodegen.java new file mode 100644 index 000000000000..016498ede6b0 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SwiftCombineClientCodegen.java @@ -0,0 +1,817 @@ +/* + * Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * Copyright 2018 SmartBear Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openapitools.codegen.languages; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.text.WordUtils; +import org.openapitools.codegen.*; +import org.openapitools.codegen.meta.GeneratorMetadata; +import org.openapitools.codegen.meta.Stability; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; +import org.openapitools.codegen.model.OperationsMap; +import org.openapitools.codegen.utils.ModelUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.time.OffsetDateTime; +import java.time.Instant; +import java.time.temporal.ChronoField; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER; +import static org.openapitools.codegen.utils.StringUtils.camelize; + +public class SwiftCombineClientCodegen extends DefaultCodegen implements CodegenConfig { + private final Logger LOGGER = LoggerFactory.getLogger(SwiftCombineClientCodegen.class); + + public static final String PROJECT_NAME = "projectName"; + public static final String MAP_FILE_BINARY_TO_DATA = "mapFileBinaryToData"; + protected String projectName = "OpenAPIClient"; + protected String privateFolder = "Sources/Private"; + protected String sourceFolder = "Sources"; + protected String transportFolder = "OpenAPITransport"; + protected List notCodableTypes = Arrays.asList("Any", "AnyObject", "[String: Any]", "[String: [String: Any]]", "[Any]"); + protected boolean mapFileBinaryToData = true; + + protected boolean anyDecoderWasAdded = false; + + /** + * Constructor for the swift language codegen module. + */ + public SwiftCombineClientCodegen() { + super(); + this.supportsMultipleInheritance = true; + this.useOneOfInterfaces = true; + this.supportsAdditionalPropertiesWithComposedSchema = true; + + generatorMetadata = GeneratorMetadata.newBuilder(generatorMetadata) + .stability(Stability.STABLE) + .build(); + + outputFolder = "generated-code" + File.separator + "swift"; + modelTemplateFiles.put("model.mustache", ".swift"); + apiTemplateFiles.put("api.mustache", ".swift"); + embeddedTemplateDir = templateDir = "swift-combine"; + apiPackage = File.separator + "APIs"; + modelPackage = File.separator + "Models"; + + languageSpecificPrimitives = new HashSet<>( + Arrays.asList( + "Int", + "Int32", + "Int64", + "Float", + "Double", + "Bool", + "Void", + "String", + "Data", + "Date", + "Character", + "UUID", + "URL", + "AnyObject", + "Any", + "[String: Any]", + "Decimal") + ); + defaultIncludes = new HashSet<>( + Arrays.asList( + "Data", + "Date", + "URL", // for file + "UUID", + "Array", + "Dictionary", + "Set", + "Any", + "Empty", + "AnyObject", + "Decimal") + ); + + reservedWords = new HashSet<>( + Arrays.asList( + // Swift keywords. This list is taken from here: + // https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/LexicalStructure.html#//apple_ref/doc/uid/TP40014097-CH30-ID410 + // + // Keywords used in declarations + "associatedtype", "class", "deinit", "enum", "extension", "fileprivate", "func", "import", "init", + "inout", "internal", "let", "open", "operator", "private", "protocol", "public", "static", "struct", + "subscript", "typealias", "var", + // Keywords uses in statements + "break", "case", "continue", "default", "defer", "do", "else", "fallthrough", "for", "guard", "if", + "in", "repeat", "return", "switch", "where", "while", + // Keywords used in expressions and types + "as", "Any", "catch", "false", "is", "nil", "rethrows", "super", "self", "Self", "throw", "throws", "true", "try", + // Keywords used in patterns + "_", + // Keywords that begin with a number sign + "#available", "#colorLiteral", "#column", "#else", "#elseif", "#endif", "#file", "#fileLiteral", "#function", "#if", + "#imageLiteral", "#line", "#selector", "#sourceLocation", + // Keywords reserved in particular contexts + "associativity", "convenience", "dynamic", "didSet", "final", "get", "infix", "indirect", "lazy", "left", + "mutating", "none", "nonmutating", "optional", "override", "postfix", "precedence", "prefix", "Protocol", + "required", "right", "set", "Type", "unowned", "weak", "willSet", + + // + // Swift Standard Library types + // https://developer.apple.com/documentation/swift + // + // Numbers and Basic Values + "Bool", "Int", "Double", "Float", "Range", "ClosedRange", "Error", "Optional", + // Special-Use Numeric Types + "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "Int8", "Int16", "Int32", "Int64", "Float80", "Float32", "Float64", + // Strings and Text + "String", "Character", "Unicode", "StaticString", + // Collections + "Array", "Dictionary", "Set", "OptionSet", "CountableRange", "CountableClosedRange", + + // The following are commonly-used Foundation types + "URL", "Data", "Codable", "Encodable", "Decodable", "Result", + + // The following are other words we want to reserve + "Void", "AnyObject", "Class", "dynamicType", "COLUMN", "FILE", "FUNCTION", "LINE" + ) + ); + + typeMapping = new HashMap<>(); + typeMapping.put("array", "Array"); + typeMapping.put("map", "Dictionary"); + typeMapping.put("set", "Set"); + typeMapping.put("date", "Date"); + typeMapping.put("Date", "Date"); + typeMapping.put("DateTime", "Date"); + typeMapping.put("boolean", "Bool"); + typeMapping.put("string", "String"); + typeMapping.put("char", "Character"); + typeMapping.put("short", "Int"); + typeMapping.put("int", "Int"); + typeMapping.put("long", "Int64"); + typeMapping.put("integer", "Int"); + typeMapping.put("Integer", "Int"); + typeMapping.put("float", "Float"); + typeMapping.put("number", "Double"); + typeMapping.put("double", "Double"); + typeMapping.put("ByteArray", "Data"); + typeMapping.put("UUID", "UUID"); + typeMapping.put("URI", "String"); + typeMapping.put("decimal", "Decimal"); + typeMapping.put("object", "[String: Any]"); + typeMapping.put("AnyType", "Any"); + typeMapping.put("file", "Data"); + typeMapping.put("binary", "Data"); + + instantiationTypes.put("array", "Array"); + instantiationTypes.put("list", "Array"); + + importMapping = new HashMap<>(); + + cliOptions.add(new CliOption(PROJECT_NAME, "Project name in Xcode")); + cliOptions.add(new CliOption(CodegenConstants.API_NAME_PREFIX, CodegenConstants.API_NAME_PREFIX_DESC)); + cliOptions.add(new CliOption(MAP_FILE_BINARY_TO_DATA, + "Map File and Binary to Data (default: true)") + .defaultValue(Boolean.TRUE.toString())); + } + + @Override + public CodegenType getTag() { + return CodegenType.CLIENT; + } + + @Override + public String getName() { + return "swift-combine"; + } + + @Override + public String getHelp() { + return "Generates a Swift Combine client library."; + } + + @Override + public void processOpts() { + super.processOpts(); + + anyDecoderWasAdded = false; + if (StringUtils.isEmpty(System.getenv("SWIFT_POST_PROCESS_FILE"))) { + LOGGER.info("Environment variable SWIFT_POST_PROCESS_FILE not defined so the Swift code may not be properly formatted. To define it, try 'export SWIFT_POST_PROCESS_FILE=/usr/local/bin/swiftformat' (Linux/Mac)"); + LOGGER.info("NOTE: To enable file post-processing, 'enablePostProcessFile' must be set to `true` (--enable-post-process-file for CLI)."); + } + + // Setup project name + if (additionalProperties.containsKey(PROJECT_NAME)) { + setProjectName((String) additionalProperties.get(PROJECT_NAME)); + } else { + additionalProperties.put(PROJECT_NAME, projectName); + } + + supportingFiles.add(new SupportingFile("Package.mustache", + projectName, + "Package.swift")); + supportingFiles.add(new SupportingFile("OpenAPITransportPackage.mustache", + transportFolder, + "Package.swift")); + supportingFiles.add(new SupportingFile("OpenAPITransport.mustache", + transportFolder + File.separator + sourceFolder, + "OpenAPITransport.swift")); + supportingFiles.add(new SupportingFile("OpenISO8601DateFormatter.mustache", + projectName + File.separator + privateFolder, + "OpenISO8601DateFormatter.swift")); + if (additionalProperties.containsKey(MAP_FILE_BINARY_TO_DATA)) { + mapFileBinaryToData = convertPropertyToBooleanAndWriteBack(MAP_FILE_BINARY_TO_DATA); + } + additionalProperties.put(MAP_FILE_BINARY_TO_DATA, mapFileBinaryToData); + if (mapFileBinaryToData) { + typeMapping.put("file", "Data"); + typeMapping.put("binary", "Data"); + } else { + typeMapping.put("file", "URL"); + typeMapping.put("binary", "URL"); + } + } + + @Override + protected boolean isReservedWord(String word) { + return word != null && reservedWords.contains(word); //don't lowercase as super does + } + + @Override + public String escapeReservedWord(String name) { + if (this.reservedWordsMappings().containsKey(name)) { + return this.reservedWordsMappings().get(name); + } + return "_" + name; + } + + @Override + public String modelFileFolder() { + return outputFolder + File.separator + projectName + File.separator + sourceFolder + + modelPackage().replace('.', File.separatorChar); + } + + @Override + public String apiFileFolder() { + return outputFolder + File.separator + projectName + File.separator + sourceFolder + + apiPackage().replace('.', File.separatorChar); + } + + @Override + public String getTypeDeclaration(Schema p) { + Schema schema = ModelUtils.unaliasSchema(this.openAPI, p, importMapping); + Schema target = ModelUtils.isGenerateAliasAsModel() ? p : schema; + if (ModelUtils.isArraySchema(target)) { + Schema items = getSchemaItems((ArraySchema) schema); + return ModelUtils.isSet(target) && ModelUtils.isObjectSchema(items) ? "Set<" + getTypeDeclaration(items) + ">" : "[" + getTypeDeclaration(items) + "]"; + } else if (ModelUtils.isMapSchema(target)) { + // Note: ModelUtils.isMapSchema(p) returns true when p is a composed schema that also defines + // additionalproperties: true + Schema inner = getAdditionalProperties(target); + if (inner == null) { + LOGGER.error("`{}` (map property) does not have a proper inner type defined. Default to type:string", p.getName()); + inner = new StringSchema().description("TODO default missing map inner type to string"); + p.setAdditionalProperties(inner); + } + return "[String: " + getTypeDeclaration(inner) + "]"; + } else if (ModelUtils.isComposedSchema(target)) { + List schemas = ModelUtils.getInterfaces((ComposedSchema)target); + if (schemas.size() == 1) { + return getTypeDeclaration(schemas.get(0)); + } else { + super.getTypeDeclaration(target); + } + } + return super.getTypeDeclaration(target); + } + + @Override + public String getSchemaType(Schema p) { + String openAPIType = super.getSchemaType(p); + String type; + if (typeMapping.containsKey(openAPIType)) { + type = typeMapping.get(openAPIType); + if (languageSpecificPrimitives.contains(type) || defaultIncludes.contains(type)) { + return type; + } + } else { + type = openAPIType; + } + return toModelName(type); + } + + @Override + public boolean isDataTypeFile(String dataType) { + return "URL".equals(dataType); + } + + @Override + public boolean isDataTypeBinary(final String dataType) { + return "Data".equals(dataType); + } + + /** + * Output the proper model name (capitalized). + * + * @param name the name of the model + * @return capitalized model name + */ + @Override + public String toModelName(String name) { + // FIXME parameter should not be assigned. Also declare it as "final" + name = sanitizeName(name); + + if (!StringUtils.isEmpty(modelNameSuffix)) { // set model suffix + name = name + "_" + modelNameSuffix; + } + + if (!StringUtils.isEmpty(modelNamePrefix)) { // set model prefix + name = modelNamePrefix + "_" + name; + } + + // camelize the model name + // phone_number => PhoneNumber + name = camelize(name); + + // model name cannot use reserved keyword, e.g. return + if (isReservedWord(name)) { + String modelName = "Model" + name; + LOGGER.warn("{} (reserved word) cannot be used as model name. Renamed to {}", name, modelName); + return modelName; + } + + // model name starts with number + if (name.matches("^\\d.*")) { + // e.g. 200Response => Model200Response (after camelize) + String modelName = "Model" + name; + LOGGER.warn("{} (model name starts with number) cannot be used as model name. Renamed to {}", name, + modelName); + return modelName; + } + + return name; + } + + /** + * Return the capitalized file name of the model. + * + * @param name the model name + * @return the file name of the model + */ + @Override + public String toModelFilename(String name) { + // should be the same as the model name + return toModelName(name); + } + + @Override + public String toDefaultValue(Schema p) { + if (p.getEnum() != null && !p.getEnum().isEmpty()) { + if (p.getDefault() != null) { + if (ModelUtils.isStringSchema(p)) { + return "." + toEnumVarName(escapeText((String) p.getDefault()), p.getType()); + } else { + return "." + toEnumVarName(escapeText(p.getDefault().toString()), p.getType()); + } + } + } + if (p.getDefault() != null) { + if (ModelUtils.isIntegerSchema(p) || ModelUtils.isNumberSchema(p) || ModelUtils.isBooleanSchema(p)) { + return p.getDefault().toString(); + } else if (ModelUtils.isDateTimeSchema(p)) { + // Datetime time stamps in Swift are expressed as Seconds with Microsecond precision. + // In Java, we need to be creative to get the Timestamp in Microseconds as a long. + Instant instant = ((OffsetDateTime) p.getDefault()).toInstant(); + long epochMicro = TimeUnit.SECONDS.toMicros(instant.getEpochSecond()) + (instant.get(ChronoField.MICRO_OF_SECOND)); + return "Date(timeIntervalSince1970: " + String.valueOf(epochMicro) + ".0 / 1_000_000)"; + } else if (ModelUtils.isUUIDSchema(p)) { + return "\"" + escapeText(p.getDefault().toString()) + "\""; + } else if (ModelUtils.isStringSchema(p)) { + return "\"" + escapeText((String) p.getDefault()) + "\""; + } + // TODO: Handle more cases from `ModelUtils`, such as Date + } + return null; + } + + @Override + public String toInstantiationType(Schema p) { + if (ModelUtils.isMapSchema(p)) { + return getSchemaType(getAdditionalProperties(p)); + } else if (ModelUtils.isArraySchema(p)) { + ArraySchema ap = (ArraySchema) p; + String inner = getSchemaType(ap.getItems()); + return ModelUtils.isSet(p) ? "Set<" + inner + ">" : "[" + inner + "]"; + } + return null; + } + + @Override + public String toApiName(String name) { + if (name.length() == 0) { + return "DefaultAPI"; + } + return camelize(apiNamePrefix + "_" + name) + "API"; + } + + @Override + public String toModelDocFilename(String name) { + return toModelName(name); + } + + @Override + public String toApiDocFilename(String name) { + return toApiName(name); + } + + @Override + public String toOperationId(String operationId) { + operationId = camelize(sanitizeName(operationId), LOWERCASE_FIRST_LETTER); + + // Throw exception if method name is empty. + // This should not happen but keep the check just in case + if (StringUtils.isEmpty(operationId)) { + throw new RuntimeException("Empty method name (operationId) not allowed"); + } + + // method name cannot use reserved keyword, e.g. return + if (isReservedWord(operationId)) { + String newOperationId = camelize(("call_" + operationId), LOWERCASE_FIRST_LETTER); + LOGGER.warn("{} (reserved word) cannot be used as method name. Renamed to {}", operationId, newOperationId); + return newOperationId; + } + + // operationId starts with a number + if (operationId.matches("^\\d.*")) { + LOGGER.warn("{} (starting with a number) cannot be used as method name. Renamed to {}", operationId, camelize(sanitizeName("call_" + operationId), LOWERCASE_FIRST_LETTER)); + operationId = camelize(sanitizeName("call_" + operationId), LOWERCASE_FIRST_LETTER); + } + + + return operationId; + } + + @Override + public String toVarName(String name) { + // sanitize name + name = sanitizeName(name); + + // if it's all upper case, do nothing + if (name.matches("^[A-Z_]*$")) { + return name; + } + + // camelize the variable name + // pet_id => petId + name = camelize(name, LOWERCASE_FIRST_LETTER); + + // for reserved words surround with `` or append _ + if (isReservedWord(name)) { + name = escapeReservedWord(name); + } + + // for words starting with number, append _ + if (name.matches("^\\d.*")) { + name = "_" + name; + } + + return name; + } + + @Override + public String toParamName(String name) { + // sanitize name + name = sanitizeName(name); + + // replace - with _ e.g. created-at => created_at + name = name.replaceAll("-", "_"); + + // if it's all upper case, do nothing + if (name.matches("^[A-Z_]*$")) { + return name; + } + + // camelize(lower) the variable name + // pet_id => petId + name = camelize(name, LOWERCASE_FIRST_LETTER); + + // for reserved words surround with `` + if (isReservedWord(name)) { + name = escapeReservedWord(name); + } + + // for words starting with number, append _ + if (name.matches("^\\d.*")) { + name = "_" + name; + } + + return name; + } + + @Override + public CodegenModel fromModel(String name, Schema schema) { + CodegenModel m = super.fromModel(name, schema); + m.optionalVars = m.optionalVars.stream().distinct().collect(Collectors.toList()); + // Update allVars/requiredVars/optionalVars with isInherited + // Each of these lists contains elements that are similar, but they are all cloned + // via CodegenModel.removeAllDuplicatedProperty and therefore need to be updated + // separately. + // First find only the parent vars via baseName matching + Map allVarsMap = m.allVars.stream() + .collect(Collectors.toMap(CodegenProperty::getBaseName, Function.identity())); + allVarsMap.keySet() + .removeAll(m.vars.stream().map(CodegenProperty::getBaseName).collect(Collectors.toSet())); + // Update the allVars + allVarsMap.values().forEach(p -> p.isInherited = true); + // Update any other vars (requiredVars, optionalVars) + Stream.of(m.requiredVars, m.optionalVars) + .flatMap(List::stream) + .filter(p -> allVarsMap.containsKey(p.baseName)) + .forEach(p -> p.isInherited = true); + return m; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + @Override + public String toEnumValue(String value, String datatype) { + // for string, array of string + if ("String".equals(datatype) || "[String]".equals(datatype) || "[String: String]".equals(datatype)) { + return "\"" + String.valueOf(value) + "\""; + } else { + return String.valueOf(value); + } + } + + @Override + public String toEnumDefaultValue(String value, String datatype) { + return datatype + "_" + value; + } + + @Override + public String toEnumVarName(String name, String datatype) { + if (name.length() == 0) { + return "empty"; + } + + Pattern startWithNumberPattern = Pattern.compile("^\\d+"); + Matcher startWithNumberMatcher = startWithNumberPattern.matcher(name); + if (startWithNumberMatcher.find()) { + String startingNumbers = startWithNumberMatcher.group(0); + String nameWithoutStartingNumbers = name.substring(startingNumbers.length()); + + return "_" + startingNumbers + camelize(nameWithoutStartingNumbers, LOWERCASE_FIRST_LETTER); + } + + // for symbol, e.g. $, # + if (getSymbolName(name) != null) { + return camelize(WordUtils.capitalizeFully(getSymbolName(name).toUpperCase(Locale.ROOT)), LOWERCASE_FIRST_LETTER); + } + + // Camelize only when we have a structure defined below + Boolean camelized = false; + if (name.matches("[A-Z][a-z0-9]+[a-zA-Z0-9]*")) { + name = camelize(name, LOWERCASE_FIRST_LETTER); + camelized = true; + } + + // Reserved Name + String nameLowercase = StringUtils.lowerCase(name); + if (isReservedWord(nameLowercase)) { + return escapeReservedWord(nameLowercase); + } + + // Check for numerical conversions + if ("Int".equals(datatype) || "Int32".equals(datatype) || "Int64".equals(datatype) + || "Float".equals(datatype) || "Double".equals(datatype)) { + String varName = "number" + camelize(name); + varName = varName.replaceAll("-", "minus"); + varName = varName.replaceAll("\\+", "plus"); + varName = varName.replaceAll("\\.", "dot"); + return varName; + } + + // If we have already camelized the word, don't progress + // any further + if (camelized) { + return name; + } + + char[] separators = {'-', '_', ' ', ':', '(', ')'}; + return camelize(WordUtils.capitalizeFully(StringUtils.lowerCase(name), separators) + .replaceAll("[-_ :\\(\\)]", ""), + LOWERCASE_FIRST_LETTER); + } + + @Override + public String toEnumName(CodegenProperty property) { + String enumName = toModelName(property.name); + + // Ensure that the enum type doesn't match a reserved word or + // the variable name doesn't match the generated enum type or the + // Swift compiler will generate an error + if (isReservedWord(property.datatypeWithEnum) + || toVarName(property.name).equals(property.datatypeWithEnum)) { + enumName = property.datatypeWithEnum + "Enum"; + } + + // TODO: toModelName already does something for names starting with number, + // so this code is probably never called + if (enumName.matches("\\d.*")) { // starts with number + return "_" + enumName; + } else { + return enumName; + } + } + + @Override + public ModelsMap postProcessModels(ModelsMap objs) { + ModelsMap postProcessedModelsEnum = postProcessModelsEnum(objs); + List models = (List) postProcessedModelsEnum.get("models"); + for (Object _mo : models) { + Map mo = (Map) _mo; + CodegenModel cm = (CodegenModel) mo.get("model"); + boolean modelHasPropertyWithEscapedName = false; + for (CodegenProperty prop : cm.allVars) { + if (!prop.name.equals(prop.baseName)) { + prop.vendorExtensions.put("x-codegen-escaped-property-name", true); + modelHasPropertyWithEscapedName = true; + } + if (notCodableTypes.contains(prop.dataType) || notCodableTypes.contains(prop.baseType)) { + prop.vendorExtensions.put("x-swift-is-not-codable", true); + } + if (modelHasPropertyWithEscapedName || notCodableTypes.contains(prop.dataType) || notCodableTypes.contains(prop.baseType)) { + cm.vendorExtensions.put("x-swift-contains-not-codable", true); + addAnyDecoderIfNeeded(); + } + } + if (modelHasPropertyWithEscapedName) { + cm.vendorExtensions.put("x-codegen-has-escaped-property-names", true); + } + } + + return postProcessedModelsEnum; + } + + @Override + public String escapeQuotationMark(String input) { + // remove " to avoid code injection + return input.replace("\"", ""); + } + + @Override + public String escapeUnsafeCharacters(String input) { + return input.replace("*/", "*_/").replace("/*", "/_*"); + } + + @Override + public void postProcessFile(File file, String fileType) { + if (file == null) { + return; + } + String swiftPostProcessFile = System.getenv("SWIFT_POST_PROCESS_FILE"); + if (StringUtils.isEmpty(swiftPostProcessFile)) { + return; // skip if SWIFT_POST_PROCESS_FILE env variable is not defined + } + // only process files with swift extension + if ("swift".equals(FilenameUtils.getExtension(file.toString()))) { + String command = swiftPostProcessFile + " " + file.toString(); + try { + Process p = Runtime.getRuntime().exec(command); + int exitValue = p.waitFor(); + if (exitValue != 0) { + LOGGER.error("Error running the command ({}). Exit value: {}", command, exitValue); + } else { + LOGGER.info("Successfully executed: {}", command); + } + } catch (InterruptedException | IOException e) { + LOGGER.error("Error running the command ({}). Exception: {}", command, e.getMessage()); + // Restore interrupted state + Thread.currentThread().interrupt(); + } + } + } + + @Override + public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { + Map objectMap = (Map) objs.get("operations"); + + HashMap modelMaps = new HashMap(); + for (Object o : allModels) { + HashMap h = (HashMap) o; + CodegenModel m = (CodegenModel) h.get("model"); + modelMaps.put(m.classname, m); + } + + List operations = (List) objectMap.get("operation"); + for (CodegenOperation operation : operations) { + operation.allParams.forEach(cp -> addVendorExtensions(cp, operation, modelMaps)); + operation.queryParams.forEach(cp -> addVendorExtensions(cp, operation, modelMaps)); + operation.headerParams.forEach(cp -> addVendorExtensions(cp, operation, modelMaps)); + operation.bodyParams.forEach(cp -> addVendorExtensions(cp, operation, modelMaps)); + operation.formParams.forEach(cp -> addFormVendorExtensions(cp, operation, modelMaps)); + if (notCodableTypes.contains(operation.returnType) || notCodableTypes.contains(operation.returnBaseType)) { + operation.vendorExtensions.put("x-swift-is-not-codable", true); + addAnyDecoderIfNeeded(); + } + List responses = operation.responses; + for (CodegenResponse response : responses) { + if (response.is4xx || response.is5xx) { + response.vendorExtensions.put("x-swift-has-custom-error-type", true); + response.vendorExtensions.put("x-swift-custom-error-type", WordUtils.capitalize(operation.operationId) + "Error"); + operation.vendorExtensions.put("x-swift-custom-error-type", WordUtils.capitalize(operation.operationId) + "Error"); + } + response.vendorExtensions.put("x-swift-is-response-code-explicit", !response.code.contains("x")); + } + } + return objs; + } + + protected void addVendorExtensions(CodegenParameter cp, CodegenOperation operation, HashMap modelMaps) { + CodegenModel model = modelMaps.get(cp.dataType); + cp.vendorExtensions.put("x-swift-use-encoder", cp.isModel); + if (cp.isArray && cp.items != null) { + CodegenModel baseModel = modelMaps.get(cp.items.dataType); + boolean isBaseTypeEnum = cp.items.isEnum || cp.isEnum || (baseModel != null && baseModel.isEnum); + cp.vendorExtensions.put("x-swift-is-base-type-enum", isBaseTypeEnum); + boolean isBaseTypeUdid = cp.items.isUuid || cp.isUuid; + cp.vendorExtensions.put("x-swift-is-base-type-udid", isBaseTypeUdid); + + boolean useEncoder = !isBaseTypeEnum && !cp.items.isString || (baseModel != null && !baseModel.isString); + cp.vendorExtensions.put("x-swift-use-encoder", useEncoder); + } + if (cp.isEnum || (model != null && model.isEnum)) { + cp.vendorExtensions.put("x-swift-is-enum-type", true); + } + if (cp.isEnum) { + String newDataType = WordUtils.capitalize(operation.operationId) + WordUtils.capitalize(cp.enumName); + cp.vendorExtensions.put("x-swift-nested-enum-type", newDataType); + if (cp.isArray) { + if (cp.uniqueItems) { + cp.dataType = "Set<" + newDataType + ">"; + } else { + cp.dataType = "[" + newDataType + "]"; + } + } else { + cp.baseType = cp.dataType; + cp.dataType = newDataType; + } + } + } + + protected void addFormVendorExtensions(CodegenParameter cp, CodegenOperation operation, HashMap modelMaps) { + addVendorExtensions(cp, operation, modelMaps); + if (operation.isMultipart && cp.isArray && cp.items.isFile) { + cp.vendorExtensions.put("x-swift-enumerate-multipart", true); + } + } + + @Override + public void postProcess() { + System.out.println("################################################################################"); + System.out.println("# Thanks for using OpenAPI Generator. #"); + System.out.println("# swift combine generator is contributed by @dydus0x14 and @ptiz. #"); + System.out.println("################################################################################"); + } + + @Override + public GeneratorLanguage generatorLanguage() { return GeneratorLanguage.SWIFT; } + + protected void addAnyDecoderIfNeeded() { + if (!anyDecoderWasAdded) { + supportingFiles.add(new SupportingFile("AnyDecodable.mustache", + projectName + File.separator + privateFolder, + "AnyDecodable.swift")); + anyDecoderWasAdded = true; + } + } +} diff --git a/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig b/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig index 70a9b14b2446..b435154f332a 100644 --- a/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig +++ b/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig @@ -138,3 +138,4 @@ org.openapitools.codegen.languages.TypeScriptReduxQueryClientCodegen org.openapitools.codegen.languages.TypeScriptRxjsClientCodegen org.openapitools.codegen.languages.WsdlSchemaCodegen org.openapitools.codegen.languages.XojoClientCodegen +org.openapitools.codegen.languages.SwiftCombineClientCodegen diff --git a/modules/openapi-generator/src/main/resources/swift-combine/AnyDecodable.mustache b/modules/openapi-generator/src/main/resources/swift-combine/AnyDecodable.mustache new file mode 100644 index 000000000000..0897aa88829e --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/AnyDecodable.mustache @@ -0,0 +1,117 @@ +// AnyDecodable.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +extension KeyedDecodingContainer { + func decode(_ type: Dictionary.Type, forKey key: K) throws -> Dictionary { + let container = try self.nestedContainer(keyedBy: AnyCodingKeys.self, forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: Dictionary.Type, forKey key: K) throws -> Dictionary? { + if contains(key) { + return try decode(type, forKey: key) + } + return nil + } + + func decode(_ type: Array.Type, forKey key: K) throws -> Array { + var container = try self.nestedUnkeyedContainer(forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: Array.Type, forKey key: K) throws -> Array? { + if contains(key) { + return try decode(type, forKey: key) + } + return nil + } + + func decode(_ type: Dictionary>.Type, forKey key: K) throws -> Dictionary> { + let container = try self.nestedContainer(keyedBy: AnyCodingKeys.self, forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: Dictionary>.Type, forKey key: K) throws -> Dictionary>? { + if contains(key) { + return try decode(type, forKey: key) + } + return nil + } + + func decode(_ type: Dictionary.Type) throws -> Dictionary { + var dictionary = Dictionary() + + for key in allKeys { + if let boolValue = try? decode(Bool.self, forKey: key) { + dictionary[key.stringValue] = boolValue + } else if let stringValue = try? decode(String.self, forKey: key) { + dictionary[key.stringValue] = stringValue + } else if let intValue = try? decode(Int.self, forKey: key) { + dictionary[key.stringValue] = intValue + } else if let doubleValue = try? decode(Double.self, forKey: key) { + dictionary[key.stringValue] = doubleValue + } else if let nestedDictionary = try? decode(Dictionary.self, forKey: key) { + dictionary[key.stringValue] = nestedDictionary + } else if let nestedArray = try? decode(Array.self, forKey: key) { + dictionary[key.stringValue] = nestedArray + } + } + return dictionary + } + + func decode(_ type: Dictionary>.Type) throws -> Dictionary> { + var dictionary = Dictionary>() + + for key in allKeys { + if let nestedDictionary = try? decode(Dictionary.self, forKey: key) { + dictionary[key.stringValue] = nestedDictionary + } + } + return dictionary + } +} + +extension UnkeyedDecodingContainer { + mutating func decode(_ type: Array.Type) throws -> Array { + var array: [Any] = [] + while isAtEnd == false { + if let value = try? decode(Bool.self) { + array.append(value) + } else if let value = try? decode(Double.self) { + array.append(value) + } else if let value = try? decode(String.self) { + array.append(value) + } else if let nestedDictionary = try? decode(Dictionary.self) { + array.append(nestedDictionary) + } else if let nestedArray = try? decode(Array.self) { + array.append(nestedArray) + } + } + return array + } + + mutating func decode(_ type: Dictionary.Type) throws -> Dictionary { + let nestedContainer = try self.nestedContainer(keyedBy: AnyCodingKeys.self) + return try nestedContainer.decode(type) + } +} + +private struct AnyCodingKeys: CodingKey { + var stringValue: String + + init(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? + + init?(intValue: Int) { + self.init(stringValue: "\(intValue)") + self.intValue = intValue + } +} diff --git a/modules/openapi-generator/src/main/resources/swift-combine/OpenAPITransport.mustache b/modules/openapi-generator/src/main/resources/swift-combine/OpenAPITransport.mustache new file mode 100644 index 000000000000..cd2e8195e944 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/OpenAPITransport.mustache @@ -0,0 +1,306 @@ +// OpenAPITransport.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech + +import Foundation +import Combine + +// MARK: - OpenAPITransport + +public protocol OpenAPITransport: AnyObject { + var baseURL: URL? { get } + + func send(request: URLRequest) -> AnyPublisher + + func cancelAll() +} + +public struct OpenAPITransportResponse { + public let data: Data + public let statusCode: Int + + public init(data: Data, statusCode: Int) { + self.data = data + self.statusCode = statusCode + } +} + +public struct OpenAPITransportError: Error, CustomStringConvertible, LocalizedError { + public let statusCode: Int + public let description: String + public let errorDescription: String? + /// It might be source network error + public let nestedError: Error? + /// Data may contain additional reason info (like json payload) + public let data: Data + + public init( + statusCode: Int, + description: String? = nil, + errorDescription: String? = nil, + nestedError: Error? = nil, + data: Data = Data() + ) { + self.statusCode = statusCode + self.errorDescription = errorDescription + self.nestedError = nestedError + self.data = data + if let description = description { + self.description = description + } else { + var summary = "OpenAPITransportError with status \(statusCode)" + if let nestedError = nestedError { + summary.append(contentsOf: ", \(nestedError.localizedDescription)") + } + self.description = summary + } + } +} + +// MARK: - Policy + +/// Policy to define whether response is successful or requires authentication +public protocol ResponsePolicy { + func defineState(for request: URLRequest, output: URLSession.DataTaskPublisher.Output) -> AnyPublisher +} + +public enum ResponseState { + /// Return success to client + case success + /// Return error to client + case failure + /// Repeat request + case retry +} + +// MARK: - Interceptor + +/// Define how to customize URL request before network call +public protocol Interceptor { + /// Customize request before performing. Add headers or encrypt body for example. + func intercept(request: URLRequest) -> AnyPublisher + + /// Customize response before handling. Decrypt body for example. + func intercept(output: URLSession.DataTaskPublisher.Output) -> AnyPublisher +} + +// MARK: - Transport delegate + +public protocol OpenAPITransportDelegate: AnyObject { + func willStart(request: URLRequest) + + func didFinish(request: URLRequest, response: HTTPURLResponse?, data: Data) + + func didFinish(request: URLRequest, error: Error) +} + +// MARK: - Implementation + +open class URLSessionOpenAPITransport: OpenAPITransport { + public struct Config { + public var baseURL: URL? + public var session: URLSession + public var processor: Interceptor + public var policy: ResponsePolicy + public weak var delegate: OpenAPITransportDelegate? + + public init( + baseURL: URL? = nil, + session: URLSession = .shared, + processor: Interceptor = DefaultInterceptor(), + policy: ResponsePolicy = DefaultResponsePolicy(), + delegate: OpenAPITransportDelegate? = nil + ) { + self.baseURL = baseURL + self.session = session + self.processor = processor + self.policy = policy + self.delegate = delegate + } + } + + private var cancellable = Set() + public var config: Config + public var baseURL: URL? { config.baseURL } + + public init(config: Config = .init()) { + self.config = config + } + + open func send(request: URLRequest) -> AnyPublisher { + config.processor + // Add custom headers or refresh token if needed + .intercept(request: request) + .flatMap { request -> AnyPublisher in + self.config.delegate?.willStart(request: request) + // Perform network call + return self.config.session.dataTaskPublisher(for: request) + .mapError { + self.config.delegate?.didFinish(request: request, error: $0) + return OpenAPITransportError(statusCode: $0.code.rawValue, description: "Network call finished fails") + } + .flatMap { output in + self.config.processor.intercept(output: output) + } + .flatMap { output -> AnyPublisher in + let response = output.response as? HTTPURLResponse + self.config.delegate?.didFinish(request: request, response: response, data: output.data) + return self.config.policy.defineState(for: request, output: output) + .setFailureType(to: OpenAPITransportError.self) + .flatMap { state -> AnyPublisher in + switch state { + case .success: + let transportResponse = OpenAPITransportResponse(data: output.data, statusCode: 200) + return Result.success(transportResponse).publisher.eraseToAnyPublisher() + case .retry: + return Fail(error: OpenAPITransportError.retryError).eraseToAnyPublisher() + case .failure: + let code = response?.statusCode ?? OpenAPITransportError.noResponseCode + let transportError = OpenAPITransportError(statusCode: code, data: output.data) + return Fail(error: transportError).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .retry(times: 2) { error -> Bool in + return error.statusCode == OpenAPITransportError.retryError.statusCode + }.eraseToAnyPublisher() + } + + open func cancelAll() { + cancellable.removeAll() + } +} + +public final class DefaultInterceptor: Interceptor { + public init() {} + + public func intercept(request: URLRequest) -> AnyPublisher { + Just(request) + .setFailureType(to: OpenAPITransportError.self) + .eraseToAnyPublisher() + } + + public func intercept(output: URLSession.DataTaskPublisher.Output) -> AnyPublisher { + Just(output) + .setFailureType(to: OpenAPITransportError.self) + .eraseToAnyPublisher() + } +} + +public final class DefaultResponsePolicy: ResponsePolicy { + public init() {} + + public func defineState(for request: URLRequest, output: URLSession.DataTaskPublisher.Output) -> AnyPublisher { + let state: ResponseState + switch (output.response as? HTTPURLResponse)?.statusCode { + case .some(200...299): state = .success + default: state = .failure + } + return Just(state).eraseToAnyPublisher() + } +} + +/// Custom transport errors. It begins with 6.. not to conflict with HTTP codes +public extension OpenAPITransportError { + static let incorrectAuthenticationCode = 600 + static func incorrectAuthenticationError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.incorrectAuthenticationCode, + description: "Impossible to add authentication headers to request", + errorDescription: NSLocalizedString( + "Impossible to add authentication headers to request", + comment: "Incorrect authentication" + ), + nestedError: nestedError + ) + } + + static let failedAuthenticationRefreshCode = 601 + static func failedAuthenticationRefreshError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.failedAuthenticationRefreshCode, + description: "Error while refreshing authentication", + errorDescription: NSLocalizedString( + "Error while refreshing authentication", + comment: "Failed authentication refresh" + ), + nestedError: nestedError + ) + } + + static let noResponseCode = 603 + static func noResponseError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.noResponseCode, + description: "There is no HTTP URL response", + errorDescription: NSLocalizedString( + "There is no HTTP URL response", + comment: "No response" + ), + nestedError: nestedError + ) + } + + static let badURLCode = 604 + static func badURLError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.badURLCode, + description: "Request URL cannot be created with given parameters", + errorDescription: NSLocalizedString( + "Request URL cannot be created with given parameters", + comment: "Bad URL" + ), + nestedError: nestedError + ) + } + + static let invalidResponseMappingCode = 605 + static func invalidResponseMappingError(data: Data) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.invalidResponseMappingCode, + description: "Response data cannot be expected object scheme", + errorDescription: NSLocalizedString( + "Response data cannot be expected object scheme", + comment: "Invalid response mapping" + ), + data: data + ) + } + + static let retryErrorCode = 606 + static let retryError = OpenAPITransportError(statusCode: OpenAPITransportError.retryErrorCode) +} + +// MARK: - Private + +private extension Publishers { + struct RetryIf: Publisher { + typealias Output = P.Output + typealias Failure = P.Failure + + let publisher: P + let times: Int + let condition: (P.Failure) -> Bool + + func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + guard times > 0 else { return publisher.receive(subscriber: subscriber) } + + publisher.catch { (error: P.Failure) -> AnyPublisher in + if condition(error) { + return RetryIf(publisher: publisher, times: times - 1, condition: condition).eraseToAnyPublisher() + } else { + return Fail(error: error).eraseToAnyPublisher() + } + }.receive(subscriber: subscriber) + } + } +} + +private extension Publisher { + func retry(times: Int, if condition: @escaping (Failure) -> Bool) -> Publishers.RetryIf { + Publishers.RetryIf(publisher: self, times: times, condition: condition) + } +} diff --git a/modules/openapi-generator/src/main/resources/swift-combine/OpenAPITransportPackage.mustache b/modules/openapi-generator/src/main/resources/swift-combine/OpenAPITransportPackage.mustache new file mode 100644 index 000000000000..916f6dfd64d0 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/OpenAPITransportPackage.mustache @@ -0,0 +1,25 @@ +// swift-tools-version:5.1 + +import PackageDescription + +let package = Package( + name: "OpenAPITransport", + platforms: [ + .iOS(.v13), + .macOS(.v10_15) + ], + products: [ + .library( + name: "OpenAPITransport", + targets: ["OpenAPITransport"] + ), + ], + dependencies: [], + targets: [ + .target( + name: "OpenAPITransport", + dependencies: [], + path: "Sources" + ), + ] +) diff --git a/modules/openapi-generator/src/main/resources/swift-combine/OpenISO8601DateFormatter.mustache b/modules/openapi-generator/src/main/resources/swift-combine/OpenISO8601DateFormatter.mustache new file mode 100644 index 000000000000..f6a6fc82b733 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/OpenISO8601DateFormatter.mustache @@ -0,0 +1,46 @@ +// +// OpenISO8601DateFormatter.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +// https://stackoverflow.com/a/50281094/976628 +class OpenISO8601DateFormatter: DateFormatter { + static let withoutSeconds: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .iso8601) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + return formatter + }() + + private func setup() { + calendar = Calendar(identifier: .iso8601) + locale = Locale(identifier: "en_US_POSIX") + timeZone = TimeZone(secondsFromGMT: 0) + dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + } + + static var shared = OpenISO8601DateFormatter() + + override init() { + super.init() + setup() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + override func date(from string: String) -> Date? { + if let result = super.date(from: string) { + return result + } + return OpenISO8601DateFormatter.withoutSeconds.date(from: string) + } +} diff --git a/modules/openapi-generator/src/main/resources/swift-combine/Package.mustache b/modules/openapi-generator/src/main/resources/swift-combine/Package.mustache new file mode 100644 index 000000000000..c1cc2a5f3a77 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/Package.mustache @@ -0,0 +1,25 @@ +// swift-tools-version:5.1 + +import PackageDescription + +let package = Package( + name: "{{projectName}}", + platforms: [ + .iOS(.v13), + .macOS(.v10_15) + ], + products: [ + .library( + name: "{{projectName}}", + targets: ["{{projectName}}"] + ), + ], + dependencies: [.package(path: "../OpenAPITransport")], + targets: [ + .target( + name: "{{projectName}}", + dependencies: [.byName(name: "OpenAPITransport")], + path: "Sources" + ), + ] +) diff --git a/modules/openapi-generator/src/main/resources/swift-combine/api.mustache b/modules/openapi-generator/src/main/resources/swift-combine/api.mustache new file mode 100644 index 000000000000..1ca68437d0cd --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/api.mustache @@ -0,0 +1,215 @@ +{{#operations}}// +// {{classname}}.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech + +import Foundation +import Combine +import OpenAPITransport + +{{#description}} +/// {{{.}}} {{/description}} +open class {{classname}} { + private let transport: OpenAPITransport + public var encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(OpenISO8601DateFormatter()) + return encoder + }() + public var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(OpenISO8601DateFormatter()) + return decoder + }() + public var baseURL = URL(string: "{{{basePath}}}") + + public init(_ transport: OpenAPITransport) { + self.transport = transport + } +{{#operation}} + + {{#allParams}} + {{#isEnum}} + /// + /// Enum for parameter {{paramName}} + /// + public enum {{{vendorExtensions.x-swift-nested-enum-type}}}: {{{baseType}}}, Codable, CaseIterable { + {{#allowableValues}} + {{#enumVars}} + case {{name}} = {{{value}}} + {{/enumVars}} + {{/allowableValues}} + } + {{/isEnum}} + {{/allParams}} + {{#vendorExtensions.x-swift-custom-error-type}} + public enum {{{vendorExtensions.x-swift-custom-error-type}}}: Error, CustomStringConvertible { + {{#responses}} + {{#vendorExtensions.x-swift-has-custom-error-type}} + // {{{message}}} + case code{{{code}}}Error{{#dataType}}({{{dataType}}}){{/dataType}} + {{/vendorExtensions.x-swift-has-custom-error-type}} + {{/responses}} + + public var description: String { + switch self { + {{#responses}} + {{#vendorExtensions.x-swift-has-custom-error-type}} + case .code{{{code}}}Error{{#dataType}}(let object){{/dataType}}: + return "{{{vendorExtensions.x-swift-custom-error-type}}}: {{{message}}}{{#dataType}}: \(object){{/dataType}}" + {{/vendorExtensions.x-swift-has-custom-error-type}} + {{/responses}} + } + } + } + {{/vendorExtensions.x-swift-custom-error-type}} + + {{#summary}} + /// {{{.}}} + {{/summary}} + /// - {{httpMethod}} {{{path}}}{{#notes}} + /// - {{{.}}}{{/notes}}{{#subresourceOperation}} + /// - subresourceOperation: {{.}}{{/subresourceOperation}}{{#defaultResponse}} + /// - defaultResponse: {{.}}{{/defaultResponse}} + {{#authMethods}} + /// - {{#isBasicBasic}}BASIC{{/isBasicBasic}}{{#isOAuth}}OAuth{{/isOAuth}}{{#isApiKey}}API Key{{/isApiKey}}: + /// - type: {{type}}{{#keyParamName}} {{keyParamName}} {{#isKeyInQuery}}(QUERY){{/isKeyInQuery}}{{#isKeyInHeaer}}(HEADER){{/isKeyInHeaer}}{{/keyParamName}} + /// - name: {{name}} + {{/authMethods}} + {{#hasResponseHeaders}} + /// - responseHeaders: [{{#responseHeaders}}{{{baseName}}}({{{dataType}}}){{^-last}}, {{/-last}}{{/responseHeaders}}] + {{/hasResponseHeaders}} + {{#externalDocs}} + /// - externalDocs: + {{#url}} + /// url: {{.}} + {{/url}} + {{#description}} + /// description: {{.}} + {{/description}} + {{/externalDocs}} + {{#allParams}} + /// - parameter {{paramName}}: ({{#isFormParam}}form{{/isFormParam}}{{#isQueryParam}}query{{/isQueryParam}}{{#isPathParam}}path{{/isPathParam}}{{#isHeaderParam}}header{{/isHeaderParam}}{{#isBodyParam}}body{{/isBodyParam}}) {{description}} {{^required}}(optional{{#defaultValue}}, default to {{{.}}}{{/defaultValue}}){{/required}} + {{/allParams}} + /// - returns: AnyPublisher<{{{returnType}}}{{^returnType}}Void{{/returnType}}, Error> {{description}} + {{#isDeprecated}} + @available(*, deprecated, message: "Deprecated API operation") + {{/isDeprecated}} + open func {{operationId}}({{#allParams}}{{paramName}}: {{{dataType}}}{{^required}}? = nil{{/required}}{{^-last}}, {{/-last}}{{/allParams}}) -> AnyPublisher<{{{returnType}}}{{^returnType}}Void{{/returnType}}, Error> { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + {{#pathParams}}{{#-last}}var{{/-last}}{{/pathParams}}{{^pathParams}}let{{/pathParams}} path = "{{path}}" + {{#pathParams}} + {{#required}}path = path.replacingOccurrences(of: "{{=<% %>=}}{<%baseName%>}<%={{ }}=%>", with: {{> toString}}){{/required}}{{^required}}if let {{paramName}} = {{paramName}} { path = path.replacingOccurrences(of: "{{=<% %>=}}{<%baseName%>}<%={{ }}=%>", with: {{> toString}}) } {{/required}} + {{/pathParams}} + let url = baseURL.appendingPathComponent(path) + {{#hasQueryParams}}var{{/hasQueryParams}}{{^hasQueryParams}}let{{/hasQueryParams}} components = URLComponents(url: url, resolvingAgainstBaseURL: false) + {{#hasQueryParams}} + var queryItems: [URLQueryItem] = [] + {{#queryParams}} + {{#required}}queryItems.append(URLQueryItem(name: "{{baseName}}", value: {{> toString}})){{/required}}{{^required}}if let {{paramName}} = {{paramName}} { queryItems.append(URLQueryItem(name: "{{baseName}}", value: {{> toString}})) } {{/required}} + {{/queryParams}} + components?.queryItems = queryItems + {{/hasQueryParams}} + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "{{httpMethod}}" + {{! Begin headers }} + {{#headerParams}} + {{#-last}} + var headers = [String: String]() + {{/-last}} + {{/headerParams}} + {{#headerParams}} + {{#required}}headers["{{baseName}}"] = {{> toString}}{{/required}}{{^required}}if let {{paramName}} = {{paramName}} { headers["{{baseName}}"] = {{> toString}} }{{/required}} + {{/headerParams}} + {{#headerParams}} + {{#-last}} + request.allHTTPHeaderFields = headers + {{/-last}} + {{/headerParams}} + {{! Begin body }} + {{#hasBodyParam}} + {{#bodyParam}} + request.httpBody = try self.encoder.encode({{paramName}}) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + {{/bodyParam}} + {{/hasBodyParam}} + {{! Begin multipart form }} + {{#hasFormParams}} + {{#isMultipart}} + let multipartBoundary = String(format: "Boundary+%08X%08X", arc4random(), arc4random()) + var multipartData = Data() + {{#formParams}} +{{> toMultipartFormDataAppend}} + {{/formParams}} + multipartData.append("\r\n--\(multipartBoundary)--\r\n".data(using: .utf8) ?? Data()) + request.httpBody = multipartData + request.setValue("\(multipartData.count)", forHTTPHeaderField: "Content-Length") + request.setValue("multipart/form-data; boundary=\(multipartBoundary)", forHTTPHeaderField: "Content-Type") + {{/isMultipart}} + {{^isMultipart}} + {{! Begin form urlencoded }} + var formEncodedItems: [String] = [] + {{#formParams}} + {{#required}}formEncodedItems.append("{{baseName}}=\({{> toString}})"){{/required}}{{^required}}if let {{paramName}} = {{paramName}} { formEncodedItems.append("{{baseName}}=\({{> toString}})") } {{/required}} + {{/formParams}} + request.httpBody = formEncodedItems.joined(separator: "&").data(using: .utf8) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + {{/isMultipart}} + {{/hasFormParams}} + return request + }.publisher + }.flatMap { request -> AnyPublisher<{{{returnType}}}{{^returnType}}Void{{/returnType}}, Error> in + return self.transport.send(request: request) + {{#vendorExtensions.x-swift-custom-error-type}} + .mapError { transportError -> Error in + {{#responses}} + {{#vendorExtensions.x-swift-has-custom-error-type}} + if transportError.statusCode == {{{code}}} { + {{#dataType}} + do { + let error = try self.decoder.decode({{{dataType}}}.self, from: transportError.data) + return {{{vendorExtensions.x-swift-custom-error-type}}}.code{{{code}}}Error(error) + } catch { + return error + } + {{/dataType}} + {{^dataType}} + return {{{vendorExtensions.x-swift-custom-error-type}}}.code{{{code}}}Error + {{/dataType}} + } + {{/vendorExtensions.x-swift-has-custom-error-type}} + {{/responses}} + return transportError + } + {{/vendorExtensions.x-swift-custom-error-type}} + .tryMap { response in + {{#returnType}} + {{#vendorExtensions.x-swift-is-not-codable}} + if let object = try JSONSerialization.jsonObject(with: response.data, options: []) as? {{{returnType}}} { + return object + } else { + throw OpenAPITransportError.invalidResponseMappingError(data: response.data) + } + {{/vendorExtensions.x-swift-is-not-codable}} + {{^vendorExtensions.x-swift-is-not-codable}} + try self.decoder.decode({{{returnType}}}.self, from: response.data) + {{/vendorExtensions.x-swift-is-not-codable}} + {{/returnType}} + {{^returnType}} + return () + {{/returnType}} + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } +{{/operation}} +} +{{/operations}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/model.mustache b/modules/openapi-generator/src/main/resources/swift-combine/model.mustache new file mode 100644 index 000000000000..02ec0fe4eb13 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/model.mustache @@ -0,0 +1,35 @@ +{{#models}}{{#model}}// +// {{classname}}.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +{{#description}} +/// {{.}} +{{/description}} +{{#vendorExtensions.x-is-one-of-interface}} +{{> modelOneOf}} +{{/vendorExtensions.x-is-one-of-interface}} +{{^vendorExtensions.x-is-one-of-interface}} +{{#isArray}} +{{> modelArray}} +{{/isArray}} +{{^isArray}} +{{#isEnum}} +{{> modelEnum}} +{{/isEnum}} +{{^isEnum}} +{{#vendorExtensions.x-swift-contains-not-codable}} +{{> modelObjectCustom}} +{{/vendorExtensions.x-swift-contains-not-codable}} +{{^vendorExtensions.x-swift-contains-not-codable}} +{{> modelObject}} +{{/vendorExtensions.x-swift-contains-not-codable}} +{{/isEnum}} +{{/isArray}} +{{/vendorExtensions.x-is-one-of-interface}} +{{/model}} +{{/models}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/modelArray.mustache b/modules/openapi-generator/src/main/resources/swift-combine/modelArray.mustache new file mode 100644 index 000000000000..843626158cd6 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/modelArray.mustache @@ -0,0 +1 @@ +public typealias {{classname}} = {{parent}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/modelEnum.mustache b/modules/openapi-generator/src/main/resources/swift-combine/modelEnum.mustache new file mode 100644 index 000000000000..2cb07669480e --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/modelEnum.mustache @@ -0,0 +1,7 @@ +public enum {{classname}}: {{dataType}}, Codable, CaseIterable { +{{#allowableValues}} +{{#enumVars}} + case {{{name}}} = {{{value}}} +{{/enumVars}} +{{/allowableValues}} +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/modelInlineEnumDeclaration.mustache b/modules/openapi-generator/src/main/resources/swift-combine/modelInlineEnumDeclaration.mustache new file mode 100644 index 000000000000..1b07a51b5984 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/modelInlineEnumDeclaration.mustache @@ -0,0 +1,7 @@ + public enum {{enumName}}: {{^isContainer}}{{dataType}}{{/isContainer}}{{#isContainer}}String{{/isContainer}}, Codable, CaseIterable { + {{#allowableValues}} + {{#enumVars}} + case {{{name}}} = {{{value}}} + {{/enumVars}} + {{/allowableValues}} + } \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/modelObject.mustache b/modules/openapi-generator/src/main/resources/swift-combine/modelObject.mustache new file mode 100644 index 000000000000..fbd65a9806d1 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/modelObject.mustache @@ -0,0 +1,28 @@ +{{#isDeprecated}} +@available(*, deprecated, message: "Deprecated API parameter") +{{/isDeprecated}} +public struct {{{classname}}}: Codable { +{{#allVars}} +{{#isEnum}} +{{> modelInlineEnumDeclaration}} +{{/isEnum}} +{{/allVars}} +{{#allVars}} +{{#isEnum}} + {{#description}}/// {{{.}}} + {{/description}}public var {{{name}}}: {{{datatypeWithEnum}}}{{#required}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}?{{/required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}} +{{/isEnum}} +{{^isEnum}} + {{#description}}/// {{{.}}} + {{/description}}public var {{{name}}}: {{{datatype}}}{{#required}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}?{{/required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}} +{{/isEnum}} +{{/allVars}} +{{#hasVars}} + + public init({{#allVars}}{{{name}}}: {{{datatypeWithEnum}}}{{#required}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}?{{/required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{^defaultValue}}{{^required}} = nil{{/required}}{{/defaultValue}}{{^-last}}, {{/-last}}{{/allVars}}) { + {{#allVars}} + self.{{{name}}} = {{{name}}} + {{/allVars}} + } +{{/hasVars}} +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/modelObjectCustom.mustache b/modules/openapi-generator/src/main/resources/swift-combine/modelObjectCustom.mustache new file mode 100644 index 000000000000..4b1ecf700e64 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/modelObjectCustom.mustache @@ -0,0 +1,66 @@ +{{#isDeprecated}} +@available(*, deprecated, message: "Deprecated API parameter") +{{/isDeprecated}} +public struct {{{classname}}}: Codable { +{{#allVars}} +{{#isEnum}} +{{> modelInlineEnumDeclaration}} +{{/isEnum}} +{{/allVars}} +{{#allVars}} +{{#isEnum}} + {{#description}}/// {{{.}}} + {{/description}}public var {{{name}}}: {{{datatypeWithEnum}}}{{#required}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}?{{/required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}} +{{/isEnum}} +{{^isEnum}} + {{#description}}/// {{{.}}} + {{/description}}public var {{{name}}}: {{{datatype}}}{{#required}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}?{{/required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}} +{{/isEnum}} +{{/allVars}} +{{#hasVars}} + + public init({{#allVars}}{{{name}}}: {{{datatypeWithEnum}}}{{#required}}{{#isNullable}}?{{/isNullable}}{{/required}}{{^required}}?{{/required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{^defaultValue}}{{^required}} = nil{{/required}}{{/defaultValue}}{{^-last}}, {{/-last}}{{/allVars}}) { + {{#allVars}} + self.{{{name}}} = {{{name}}} + {{/allVars}} + } +{{/hasVars}} + + public enum CodingKeys: {{#hasVars}}String, {{/hasVars}}CodingKey, CaseIterable { + {{#allVars}} + case {{{name}}}{{#vendorExtensions.x-codegen-escaped-property-name}} = "{{{baseName}}}"{{/vendorExtensions.x-codegen-escaped-property-name}} + {{/allVars}} + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + {{#allVars}} + {{{name}}} = try container.decode{{^required}}IfPresent{{/required}}({{{datatypeWithEnum}}}.self, forKey: .{{{name}}}) + {{/allVars}} + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + {{#allVars}} + {{#vendorExtensions.x-swift-is-not-codable}} + {{#required}} + try container.encode(try JSONSerialization.data(withJSONObject: {{{name}}}), forKey: .{{{name}}}) + {{/required}} + {{^required}} + if let {{{name}}} = {{{name}}} { + try container.encodeIfPresent(try JSONSerialization.data(withJSONObject: {{{name}}}), forKey: .{{{name}}}) + } + {{/required}} + {{/vendorExtensions.x-swift-is-not-codable}} + {{^vendorExtensions.x-swift-is-not-codable}} + try container.encode{{^required}}IfPresent{{/required}}({{{name}}}, forKey: .{{{name}}}) + {{/vendorExtensions.x-swift-is-not-codable}} + {{/allVars}} + {{#generateModelAdditionalProperties}} + {{#additionalPropertiesType}} + var additionalPropertiesContainer = encoder.container(keyedBy: String.self) + try additionalPropertiesContainer.encodeMap(additionalProperties) + {{/additionalPropertiesType}} + {{/generateModelAdditionalProperties}} + } +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/swift-combine/modelOneOf.mustache new file mode 100644 index 000000000000..a9fc95282106 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/modelOneOf.mustache @@ -0,0 +1,34 @@ +{{#isDeprecated}} +@available(*, deprecated, message: "Deprecated API parameter") +{{/isDeprecated}} +public enum {{classname}}: Codable { + {{#oneOf}} + case type{{.}}({{.}}) + {{/oneOf}} + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + {{#oneOf}} + case .type{{.}}(let value): + try container.encode(value) + {{/oneOf}} + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + {{#oneOf}} + {{#-first}} + if let value = try? container.decode({{.}}.self) { + {{/-first}} + {{^-first}} + } else if let value = try? container.decode({{.}}.self) { + {{/-first}} + self = .type{{.}}(value) + {{/oneOf}} + } else { + throw DecodingError.typeMismatch(Self.Type.self, .init(codingPath: decoder.codingPath, debugDescription: "Unable to decode instance of {{classname}}")) + } + } +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/toData.mustache b/modules/openapi-generator/src/main/resources/swift-combine/toData.mustache new file mode 100644 index 000000000000..a328dca35585 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/toData.mustache @@ -0,0 +1 @@ +{{#isBinary}}{{#mapFileBinaryToData}}{{{paramName}}}{{/mapFileBinaryToData}}{{^mapFileBinaryToData}}try Data(contentsOf: {{{paramName}}}){{/mapFileBinaryToData}}{{/isBinary}}{{#isModel}}try self.encoder.encode({{paramName}}){{/isModel}}{{#isByteArray}}{{paramName}}.base64EncodedData(){{/isByteArray}}{{#isString}}{{paramName}}{{#isEnum}}.rawValue{{/isEnum}}.data(using: .utf8) ?? Data(){{/isString}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/toMultipartFormDataAppend.mustache b/modules/openapi-generator/src/main/resources/swift-combine/toMultipartFormDataAppend.mustache new file mode 100644 index 000000000000..de65327b131a --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/toMultipartFormDataAppend.mustache @@ -0,0 +1,59 @@ + {{#vendorExtensions.x-swift-enumerate-multipart}} + {{^mapFileBinaryToData}}try {{/mapFileBinaryToData}}{{paramName}}{{^required}}?{{/required}}.enumerated().forEach { index, {{paramName}} in + {{#mapFileBinaryToData}} + let filename = "\({{paramName}})\(index)" + {{/mapFileBinaryToData}} + {{^mapFileBinaryToData}} + let filename = {{paramName}}.lastPathComponent + {{/mapFileBinaryToData}} + let {{paramName}}Header = "--\(multipartBoundary)\r\n" + .appending("Content-Disposition:form-data; name=\"{{paramName}}\"; filename=\"\(filename)\"\r\n") + {{#contentType}} + .appending("Content-Type: {{{contentType}}}\r\n") + {{/contentType}} + .appending("\r\n") + multipartData.append({{paramName}}Header.data(using: .utf8) ?? Data()) + multipartData.append({{#items}}{{> toData}}{{/items}}) + multipartData.append("\r\n".data(using: .utf8) ?? Data()) + } + {{/vendorExtensions.x-swift-enumerate-multipart}} + {{^vendorExtensions.x-swift-enumerate-multipart}} + {{#required}} + let {{paramName}}Header = "--\(multipartBoundary)\r\n" + {{#isFile}} + .appending("Content-Disposition:form-data; name=\"{{paramName}}\"; filename=\"{{paramName}}\"\r\n") + {{/isFile}} + {{^isFile}} + .appending("Content-Disposition:form-data; name=\"{{paramName}}\"\r\n") + {{/isFile}} + {{#contentType}} + .appending("Content-Type: {{{contentType}}}\r\n") + {{/contentType}} + .appending("\r\n") + multipartData.append({{paramName}}Header.data(using: .utf8) ?? Data()) + multipartData.append({{> toData}}) + {{^-last}} + multipartData.append("\r\n".data(using: .utf8) ?? Data()) + {{/-last}} + {{/required}} + {{^required}} + if let {{paramName}} = {{paramName}} { + let {{paramName}}Header = "--\(multipartBoundary)\r\n" + {{#isFile}} + .appending("Content-Disposition:form-data; name=\"{{paramName}}\"; filename=\"{{paramName}}\"\r\n") + {{/isFile}} + {{^isFile}} + .appending("Content-Disposition:form-data; name=\"{{paramName}}\"\r\n") + {{/isFile}} + {{#contentType}} + .appending("Content-Type: {{{contentType}}}\r\n") + {{/contentType}} + .appending("\r\n") + multipartData.append({{paramName}}Header.data(using: .utf8) ?? Data()) + multipartData.append({{> toData}}) + {{^-last}} + multipartData.append("\r\n".data(using: .utf8) ?? Data()) + {{/-last}} + } + {{/required}} + {{/vendorExtensions.x-swift-enumerate-multipart}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/swift-combine/toString.mustache b/modules/openapi-generator/src/main/resources/swift-combine/toString.mustache new file mode 100644 index 000000000000..5535cae98f1b --- /dev/null +++ b/modules/openapi-generator/src/main/resources/swift-combine/toString.mustache @@ -0,0 +1 @@ +{{#isDateTime}}OpenISO8601DateFormatter.shared.string(from: {{paramName}}){{/isDateTime}}{{#vendorExtensions.x-swift-use-encoder}}String(data: try self.encoder.encode({{paramName}}), encoding: .utf8) ?? ""{{/vendorExtensions.x-swift-use-encoder}}{{^vendorExtensions.x-swift-use-encoder}}{{#isArray}}{{paramName}}{{#vendorExtensions.x-swift-is-base-type-udid}}.map { $0.uuidString }{{/vendorExtensions.x-swift-is-base-type-udid}}{{#vendorExtensions.x-swift-is-base-type-enum}}.map { $0.rawValue }{{/vendorExtensions.x-swift-is-base-type-enum}}.joined(separator: ","){{/isArray}}{{^isArray}}{{#vendorExtensions.x-swift-is-enum-type}}{{paramName}}.rawValue{{/vendorExtensions.x-swift-is-enum-type}}{{^isEnum}}{{#isString}}{{#isUuid}}{{paramName}}.uuidString{{/isUuid}}{{^isUuid}}{{paramName}}{{/isUuid}}{{/isString}}{{#isInteger}}"\({{paramName}})"{{/isInteger}}{{#isDouble}}"\({{paramName}})"{{/isDouble}}{{#isFloat}}"\({{paramName}})"{{/isFloat}}{{#isNumber}}"\({{paramName}})"{{/isNumber}}{{#isLong}}"\({{paramName}})"{{/isLong}}{{#isBoolean}}{{paramName}} ? "true" : "false"{{/isBoolean}}{{/isEnum}}{{/isArray}}{{/vendorExtensions.x-swift-use-encoder}} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/options/SwiftCombineClientCodegenOptionsProvider.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/options/SwiftCombineClientCodegenOptionsProvider.java new file mode 100644 index 000000000000..e5bca028aa3a --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/options/SwiftCombineClientCodegenOptionsProvider.java @@ -0,0 +1,30 @@ +package org.openapitools.codegen.options; + +import org.openapitools.codegen.languages.SwiftCombineClientCodegen; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; + +public class SwiftCombineClientCodegenOptionsProvider implements OptionsProvider { + public static final String PROJECT_NAME_VALUE = "OpenAPI"; + + @Override + public String getLanguage() { + return "swift-combine"; + } + + @Override + public Map createOptions() { + ImmutableMap.Builder builder = new ImmutableMap.Builder(); + return builder + .put(SwiftCombineClientCodegen.PROJECT_NAME, PROJECT_NAME_VALUE) + .build(); + } + + @Override + public boolean isServer() { + return false; + } +} + diff --git a/samples/client/petstore/swift-combine/.gitignore b/samples/client/petstore/swift-combine/.gitignore new file mode 100644 index 000000000000..4ea5f5d525b3 --- /dev/null +++ b/samples/client/petstore/swift-combine/.gitignore @@ -0,0 +1,64 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata + +## Other +*.xccheckout +*.moved-aside +*.xcuserstate +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +.build/ +.swiftpm + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md + +fastlane/report.xml +fastlane/screenshots diff --git a/samples/client/petstore/swift-combine/client/.openapi-generator-ignore b/samples/client/petstore/swift-combine/client/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/.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/swift-combine/client/.openapi-generator/FILES b/samples/client/petstore/swift-combine/client/.openapi-generator/FILES new file mode 100644 index 000000000000..05dded974662 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/.openapi-generator/FILES @@ -0,0 +1,13 @@ +OpenAPITransport/Package.swift +OpenAPITransport/Sources/OpenAPITransport.swift +PetstoreOpenAPI/Package.swift +PetstoreOpenAPI/Sources/APIs/PetAPI.swift +PetstoreOpenAPI/Sources/APIs/StoreAPI.swift +PetstoreOpenAPI/Sources/APIs/UserAPI.swift +PetstoreOpenAPI/Sources/Models/ApiResponse.swift +PetstoreOpenAPI/Sources/Models/Category.swift +PetstoreOpenAPI/Sources/Models/Order.swift +PetstoreOpenAPI/Sources/Models/Pet.swift +PetstoreOpenAPI/Sources/Models/Tag.swift +PetstoreOpenAPI/Sources/Models/User.swift +PetstoreOpenAPI/Sources/Private/OpenISO8601DateFormatter.swift diff --git a/samples/client/petstore/swift-combine/client/.openapi-generator/VERSION b/samples/client/petstore/swift-combine/client/.openapi-generator/VERSION new file mode 100644 index 000000000000..757e67400401 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.0.0-SNAPSHOT \ No newline at end of file diff --git a/samples/client/petstore/swift-combine/client/OpenAPITransport/Package.swift b/samples/client/petstore/swift-combine/client/OpenAPITransport/Package.swift new file mode 100644 index 000000000000..916f6dfd64d0 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/OpenAPITransport/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version:5.1 + +import PackageDescription + +let package = Package( + name: "OpenAPITransport", + platforms: [ + .iOS(.v13), + .macOS(.v10_15) + ], + products: [ + .library( + name: "OpenAPITransport", + targets: ["OpenAPITransport"] + ), + ], + dependencies: [], + targets: [ + .target( + name: "OpenAPITransport", + dependencies: [], + path: "Sources" + ), + ] +) diff --git a/samples/client/petstore/swift-combine/client/OpenAPITransport/Sources/OpenAPITransport.swift b/samples/client/petstore/swift-combine/client/OpenAPITransport/Sources/OpenAPITransport.swift new file mode 100644 index 000000000000..cd2e8195e944 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/OpenAPITransport/Sources/OpenAPITransport.swift @@ -0,0 +1,306 @@ +// OpenAPITransport.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech + +import Foundation +import Combine + +// MARK: - OpenAPITransport + +public protocol OpenAPITransport: AnyObject { + var baseURL: URL? { get } + + func send(request: URLRequest) -> AnyPublisher + + func cancelAll() +} + +public struct OpenAPITransportResponse { + public let data: Data + public let statusCode: Int + + public init(data: Data, statusCode: Int) { + self.data = data + self.statusCode = statusCode + } +} + +public struct OpenAPITransportError: Error, CustomStringConvertible, LocalizedError { + public let statusCode: Int + public let description: String + public let errorDescription: String? + /// It might be source network error + public let nestedError: Error? + /// Data may contain additional reason info (like json payload) + public let data: Data + + public init( + statusCode: Int, + description: String? = nil, + errorDescription: String? = nil, + nestedError: Error? = nil, + data: Data = Data() + ) { + self.statusCode = statusCode + self.errorDescription = errorDescription + self.nestedError = nestedError + self.data = data + if let description = description { + self.description = description + } else { + var summary = "OpenAPITransportError with status \(statusCode)" + if let nestedError = nestedError { + summary.append(contentsOf: ", \(nestedError.localizedDescription)") + } + self.description = summary + } + } +} + +// MARK: - Policy + +/// Policy to define whether response is successful or requires authentication +public protocol ResponsePolicy { + func defineState(for request: URLRequest, output: URLSession.DataTaskPublisher.Output) -> AnyPublisher +} + +public enum ResponseState { + /// Return success to client + case success + /// Return error to client + case failure + /// Repeat request + case retry +} + +// MARK: - Interceptor + +/// Define how to customize URL request before network call +public protocol Interceptor { + /// Customize request before performing. Add headers or encrypt body for example. + func intercept(request: URLRequest) -> AnyPublisher + + /// Customize response before handling. Decrypt body for example. + func intercept(output: URLSession.DataTaskPublisher.Output) -> AnyPublisher +} + +// MARK: - Transport delegate + +public protocol OpenAPITransportDelegate: AnyObject { + func willStart(request: URLRequest) + + func didFinish(request: URLRequest, response: HTTPURLResponse?, data: Data) + + func didFinish(request: URLRequest, error: Error) +} + +// MARK: - Implementation + +open class URLSessionOpenAPITransport: OpenAPITransport { + public struct Config { + public var baseURL: URL? + public var session: URLSession + public var processor: Interceptor + public var policy: ResponsePolicy + public weak var delegate: OpenAPITransportDelegate? + + public init( + baseURL: URL? = nil, + session: URLSession = .shared, + processor: Interceptor = DefaultInterceptor(), + policy: ResponsePolicy = DefaultResponsePolicy(), + delegate: OpenAPITransportDelegate? = nil + ) { + self.baseURL = baseURL + self.session = session + self.processor = processor + self.policy = policy + self.delegate = delegate + } + } + + private var cancellable = Set() + public var config: Config + public var baseURL: URL? { config.baseURL } + + public init(config: Config = .init()) { + self.config = config + } + + open func send(request: URLRequest) -> AnyPublisher { + config.processor + // Add custom headers or refresh token if needed + .intercept(request: request) + .flatMap { request -> AnyPublisher in + self.config.delegate?.willStart(request: request) + // Perform network call + return self.config.session.dataTaskPublisher(for: request) + .mapError { + self.config.delegate?.didFinish(request: request, error: $0) + return OpenAPITransportError(statusCode: $0.code.rawValue, description: "Network call finished fails") + } + .flatMap { output in + self.config.processor.intercept(output: output) + } + .flatMap { output -> AnyPublisher in + let response = output.response as? HTTPURLResponse + self.config.delegate?.didFinish(request: request, response: response, data: output.data) + return self.config.policy.defineState(for: request, output: output) + .setFailureType(to: OpenAPITransportError.self) + .flatMap { state -> AnyPublisher in + switch state { + case .success: + let transportResponse = OpenAPITransportResponse(data: output.data, statusCode: 200) + return Result.success(transportResponse).publisher.eraseToAnyPublisher() + case .retry: + return Fail(error: OpenAPITransportError.retryError).eraseToAnyPublisher() + case .failure: + let code = response?.statusCode ?? OpenAPITransportError.noResponseCode + let transportError = OpenAPITransportError(statusCode: code, data: output.data) + return Fail(error: transportError).eraseToAnyPublisher() + } + }.eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .retry(times: 2) { error -> Bool in + return error.statusCode == OpenAPITransportError.retryError.statusCode + }.eraseToAnyPublisher() + } + + open func cancelAll() { + cancellable.removeAll() + } +} + +public final class DefaultInterceptor: Interceptor { + public init() {} + + public func intercept(request: URLRequest) -> AnyPublisher { + Just(request) + .setFailureType(to: OpenAPITransportError.self) + .eraseToAnyPublisher() + } + + public func intercept(output: URLSession.DataTaskPublisher.Output) -> AnyPublisher { + Just(output) + .setFailureType(to: OpenAPITransportError.self) + .eraseToAnyPublisher() + } +} + +public final class DefaultResponsePolicy: ResponsePolicy { + public init() {} + + public func defineState(for request: URLRequest, output: URLSession.DataTaskPublisher.Output) -> AnyPublisher { + let state: ResponseState + switch (output.response as? HTTPURLResponse)?.statusCode { + case .some(200...299): state = .success + default: state = .failure + } + return Just(state).eraseToAnyPublisher() + } +} + +/// Custom transport errors. It begins with 6.. not to conflict with HTTP codes +public extension OpenAPITransportError { + static let incorrectAuthenticationCode = 600 + static func incorrectAuthenticationError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.incorrectAuthenticationCode, + description: "Impossible to add authentication headers to request", + errorDescription: NSLocalizedString( + "Impossible to add authentication headers to request", + comment: "Incorrect authentication" + ), + nestedError: nestedError + ) + } + + static let failedAuthenticationRefreshCode = 601 + static func failedAuthenticationRefreshError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.failedAuthenticationRefreshCode, + description: "Error while refreshing authentication", + errorDescription: NSLocalizedString( + "Error while refreshing authentication", + comment: "Failed authentication refresh" + ), + nestedError: nestedError + ) + } + + static let noResponseCode = 603 + static func noResponseError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.noResponseCode, + description: "There is no HTTP URL response", + errorDescription: NSLocalizedString( + "There is no HTTP URL response", + comment: "No response" + ), + nestedError: nestedError + ) + } + + static let badURLCode = 604 + static func badURLError(_ nestedError: Error? = nil) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.badURLCode, + description: "Request URL cannot be created with given parameters", + errorDescription: NSLocalizedString( + "Request URL cannot be created with given parameters", + comment: "Bad URL" + ), + nestedError: nestedError + ) + } + + static let invalidResponseMappingCode = 605 + static func invalidResponseMappingError(data: Data) -> OpenAPITransportError { + OpenAPITransportError( + statusCode: OpenAPITransportError.invalidResponseMappingCode, + description: "Response data cannot be expected object scheme", + errorDescription: NSLocalizedString( + "Response data cannot be expected object scheme", + comment: "Invalid response mapping" + ), + data: data + ) + } + + static let retryErrorCode = 606 + static let retryError = OpenAPITransportError(statusCode: OpenAPITransportError.retryErrorCode) +} + +// MARK: - Private + +private extension Publishers { + struct RetryIf: Publisher { + typealias Output = P.Output + typealias Failure = P.Failure + + let publisher: P + let times: Int + let condition: (P.Failure) -> Bool + + func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + guard times > 0 else { return publisher.receive(subscriber: subscriber) } + + publisher.catch { (error: P.Failure) -> AnyPublisher in + if condition(error) { + return RetryIf(publisher: publisher, times: times - 1, condition: condition).eraseToAnyPublisher() + } else { + return Fail(error: error).eraseToAnyPublisher() + } + }.receive(subscriber: subscriber) + } + } +} + +private extension Publisher { + func retry(times: Int, if condition: @escaping (Failure) -> Bool) -> Publishers.RetryIf { + Publishers.RetryIf(publisher: self, times: times, condition: condition) + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Package.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Package.swift new file mode 100644 index 000000000000..65c060130e0e --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version:5.1 + +import PackageDescription + +let package = Package( + name: "PetstoreOpenAPI", + platforms: [ + .iOS(.v13), + .macOS(.v10_15) + ], + products: [ + .library( + name: "PetstoreOpenAPI", + targets: ["PetstoreOpenAPI"] + ), + ], + dependencies: [.package(path: "../OpenAPITransport")], + targets: [ + .target( + name: "PetstoreOpenAPI", + dependencies: [.byName(name: "OpenAPITransport")], + path: "Sources" + ), + ] +) diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/PetAPI.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/PetAPI.swift new file mode 100644 index 000000000000..952c7313304f --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/PetAPI.swift @@ -0,0 +1,503 @@ +// +// PetAPI.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech + +import Foundation +import Combine +import OpenAPITransport + + +open class PetAPI { + private let transport: OpenAPITransport + public var encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(OpenISO8601DateFormatter()) + return encoder + }() + public var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(OpenISO8601DateFormatter()) + return decoder + }() + public var baseURL = URL(string: "http://petstore.swagger.io/v2") + + public init(_ transport: OpenAPITransport) { + self.transport = transport + } + + public enum AddPetError: Error, CustomStringConvertible { + // Invalid input + case code405Error + + public var description: String { + switch self { + case .code405Error: + return "AddPetError: Invalid input" + } + } + } + + /// Add a new pet to the store + /// - POST /pet + /// - + /// - OAuth: + /// - type: oauth2 + /// - name: petstore_auth + /// - parameter pet: (body) Pet object that needs to be added to the store + /// - returns: AnyPublisher + open func addPet(pet: Pet) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/pet" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + request.httpBody = try self.encoder.encode(pet) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 405 { + return AddPetError.code405Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode(Pet.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum DeletePetError: Error, CustomStringConvertible { + // Invalid pet value + case code400Error + + public var description: String { + switch self { + case .code400Error: + return "DeletePetError: Invalid pet value" + } + } + } + + /// Deletes a pet + /// - DELETE /pet/{petId} + /// - + /// - OAuth: + /// - type: oauth2 + /// - name: petstore_auth + /// - parameter petId: (path) Pet id to delete + /// - parameter apiKey: (header) (optional) + /// - returns: AnyPublisher + open func deletePet(petId: Int64, apiKey: String? = nil) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/pet/{petId}" + path = path.replacingOccurrences(of: "{petId}", with: "\(petId)") + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "DELETE" + var headers = [String: String]() + if let apiKey = apiKey { headers["api_key"] = apiKey } + request.allHTTPHeaderFields = headers + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return DeletePetError.code400Error + } + return transportError + } + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + /// + /// Enum for parameter status + /// + public enum FindPetsByStatusStatus: String, Codable, CaseIterable { + case available = "available" + case pending = "pending" + case sold = "sold" + } + public enum FindPetsByStatusError: Error, CustomStringConvertible { + // Invalid status value + case code400Error + + public var description: String { + switch self { + case .code400Error: + return "FindPetsByStatusError: Invalid status value" + } + } + } + + /// Finds Pets by status + /// - GET /pet/findByStatus + /// - Multiple status values can be provided with comma separated strings + /// - OAuth: + /// - type: oauth2 + /// - name: petstore_auth + /// - parameter status: (query) Status values that need to be considered for filter + /// - returns: AnyPublisher<[Pet], Error> + open func findPetsByStatus(status: [FindPetsByStatusStatus]) -> AnyPublisher<[Pet], Error> { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/pet/findByStatus" + let url = baseURL.appendingPathComponent(path) + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + var queryItems: [URLQueryItem] = [] + queryItems.append(URLQueryItem(name: "status", value: status.map { $0.rawValue }.joined(separator: ","))) + components?.queryItems = queryItems + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher<[Pet], Error> in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return FindPetsByStatusError.code400Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode([Pet].self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum FindPetsByTagsError: Error, CustomStringConvertible { + // Invalid tag value + case code400Error + + public var description: String { + switch self { + case .code400Error: + return "FindPetsByTagsError: Invalid tag value" + } + } + } + + /// Finds Pets by tags + /// - GET /pet/findByTags + /// - Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + /// - OAuth: + /// - type: oauth2 + /// - name: petstore_auth + /// - parameter tags: (query) Tags to filter by + /// - returns: AnyPublisher<[Pet], Error> + @available(*, deprecated, message: "Deprecated API operation") + open func findPetsByTags(tags: [String]) -> AnyPublisher<[Pet], Error> { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/pet/findByTags" + let url = baseURL.appendingPathComponent(path) + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + var queryItems: [URLQueryItem] = [] + queryItems.append(URLQueryItem(name: "tags", value: tags.joined(separator: ","))) + components?.queryItems = queryItems + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher<[Pet], Error> in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return FindPetsByTagsError.code400Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode([Pet].self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum GetPetByIdError: Error, CustomStringConvertible { + // Invalid ID supplied + case code400Error + // Pet not found + case code404Error + + public var description: String { + switch self { + case .code400Error: + return "GetPetByIdError: Invalid ID supplied" + case .code404Error: + return "GetPetByIdError: Pet not found" + } + } + } + + /// Find pet by ID + /// - GET /pet/{petId} + /// - Returns a single pet + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - parameter petId: (path) ID of pet to return + /// - returns: AnyPublisher + open func getPetById(petId: Int64) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/pet/{petId}" + path = path.replacingOccurrences(of: "{petId}", with: "\(petId)") + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return GetPetByIdError.code400Error + } + if transportError.statusCode == 404 { + return GetPetByIdError.code404Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode(Pet.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum UpdatePetError: Error, CustomStringConvertible { + // Invalid ID supplied + case code400Error + // Pet not found + case code404Error + // Validation exception + case code405Error + + public var description: String { + switch self { + case .code400Error: + return "UpdatePetError: Invalid ID supplied" + case .code404Error: + return "UpdatePetError: Pet not found" + case .code405Error: + return "UpdatePetError: Validation exception" + } + } + } + + /// Update an existing pet + /// - PUT /pet + /// - + /// - OAuth: + /// - type: oauth2 + /// - name: petstore_auth + /// - externalDocs: + /// url: http://petstore.swagger.io/v2/doc/updatePet + /// description: API documentation for the updatePet operation + /// - parameter pet: (body) Pet object that needs to be added to the store + /// - returns: AnyPublisher + open func updatePet(pet: Pet) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/pet" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "PUT" + request.httpBody = try self.encoder.encode(pet) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return UpdatePetError.code400Error + } + if transportError.statusCode == 404 { + return UpdatePetError.code404Error + } + if transportError.statusCode == 405 { + return UpdatePetError.code405Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode(Pet.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum UpdatePetWithFormError: Error, CustomStringConvertible { + // Invalid input + case code405Error + + public var description: String { + switch self { + case .code405Error: + return "UpdatePetWithFormError: Invalid input" + } + } + } + + /// Updates a pet in the store with form data + /// - POST /pet/{petId} + /// - + /// - OAuth: + /// - type: oauth2 + /// - name: petstore_auth + /// - parameter petId: (path) ID of pet that needs to be updated + /// - parameter name: (form) Updated name of the pet (optional) + /// - parameter status: (form) Updated status of the pet (optional) + /// - returns: AnyPublisher + open func updatePetWithForm(petId: Int64, name: String? = nil, status: String? = nil) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/pet/{petId}" + path = path.replacingOccurrences(of: "{petId}", with: "\(petId)") + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + var formEncodedItems: [String] = [] + if let name = name { formEncodedItems.append("name=\(name)") } + if let status = status { formEncodedItems.append("status=\(status)") } + request.httpBody = formEncodedItems.joined(separator: "&").data(using: .utf8) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 405 { + return UpdatePetWithFormError.code405Error + } + return transportError + } + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + + /// uploads an image + /// - POST /pet/{petId}/uploadImage + /// - + /// - OAuth: + /// - type: oauth2 + /// - name: petstore_auth + /// - parameter petId: (path) ID of pet to update + /// - parameter additionalMetadata: (form) Additional data to pass to server (optional) + /// - parameter file: (form) file to upload (optional) + /// - returns: AnyPublisher + open func uploadFile(petId: Int64, additionalMetadata: String? = nil, file: Data? = nil) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/pet/{petId}/uploadImage" + path = path.replacingOccurrences(of: "{petId}", with: "\(petId)") + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + let multipartBoundary = String(format: "Boundary+%08X%08X", arc4random(), arc4random()) + var multipartData = Data() + if let additionalMetadata = additionalMetadata { + let additionalMetadataHeader = "--\(multipartBoundary)\r\n" + .appending("Content-Disposition:form-data; name=\"additionalMetadata\"\r\n") + .appending("\r\n") + multipartData.append(additionalMetadataHeader.data(using: .utf8) ?? Data()) + multipartData.append(additionalMetadata.data(using: .utf8) ?? Data()) + multipartData.append("\r\n".data(using: .utf8) ?? Data()) + } + + if let file = file { + let fileHeader = "--\(multipartBoundary)\r\n" + .appending("Content-Disposition:form-data; name=\"file\"; filename=\"file\"\r\n") + .appending("\r\n") + multipartData.append(fileHeader.data(using: .utf8) ?? Data()) + multipartData.append(file) + } + + multipartData.append("\r\n--\(multipartBoundary)--\r\n".data(using: .utf8) ?? Data()) + request.httpBody = multipartData + request.setValue("\(multipartData.count)", forHTTPHeaderField: "Content-Length") + request.setValue("multipart/form-data; boundary=\(multipartBoundary)", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .tryMap { response in + try self.decoder.decode(ApiResponse.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/StoreAPI.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/StoreAPI.swift new file mode 100644 index 000000000000..ffd6596b1479 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/StoreAPI.swift @@ -0,0 +1,224 @@ +// +// StoreAPI.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech + +import Foundation +import Combine +import OpenAPITransport + + +open class StoreAPI { + private let transport: OpenAPITransport + public var encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(OpenISO8601DateFormatter()) + return encoder + }() + public var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(OpenISO8601DateFormatter()) + return decoder + }() + public var baseURL = URL(string: "http://petstore.swagger.io/v2") + + public init(_ transport: OpenAPITransport) { + self.transport = transport + } + + public enum DeleteOrderError: Error, CustomStringConvertible { + // Invalid ID supplied + case code400Error + // Order not found + case code404Error + + public var description: String { + switch self { + case .code400Error: + return "DeleteOrderError: Invalid ID supplied" + case .code404Error: + return "DeleteOrderError: Order not found" + } + } + } + + /// Delete purchase order by ID + /// - DELETE /store/order/{orderId} + /// - For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors + /// - parameter orderId: (path) ID of the order that needs to be deleted + /// - returns: AnyPublisher + open func deleteOrder(orderId: String) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/store/order/{orderId}" + path = path.replacingOccurrences(of: "{orderId}", with: orderId) + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "DELETE" + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return DeleteOrderError.code400Error + } + if transportError.statusCode == 404 { + return DeleteOrderError.code404Error + } + return transportError + } + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + + /// Returns pet inventories by status + /// - GET /store/inventory + /// - Returns a map of status codes to quantities + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - returns: AnyPublisher<[String: Int], Error> + open func getInventory() -> AnyPublisher<[String: Int], Error> { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/store/inventory" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher<[String: Int], Error> in + return self.transport.send(request: request) + .tryMap { response in + try self.decoder.decode([String: Int].self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum GetOrderByIdError: Error, CustomStringConvertible { + // Invalid ID supplied + case code400Error + // Order not found + case code404Error + + public var description: String { + switch self { + case .code400Error: + return "GetOrderByIdError: Invalid ID supplied" + case .code404Error: + return "GetOrderByIdError: Order not found" + } + } + } + + /// Find purchase order by ID + /// - GET /store/order/{orderId} + /// - For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions + /// - parameter orderId: (path) ID of pet that needs to be fetched + /// - returns: AnyPublisher + open func getOrderById(orderId: Int64) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/store/order/{orderId}" + path = path.replacingOccurrences(of: "{orderId}", with: "\(orderId)") + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return GetOrderByIdError.code400Error + } + if transportError.statusCode == 404 { + return GetOrderByIdError.code404Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode(Order.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum PlaceOrderError: Error, CustomStringConvertible { + // Invalid Order + case code400Error + + public var description: String { + switch self { + case .code400Error: + return "PlaceOrderError: Invalid Order" + } + } + } + + /// Place an order for a pet + /// - POST /store/order + /// - + /// - parameter order: (body) order placed for purchasing the pet + /// - returns: AnyPublisher + open func placeOrder(order: Order) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/store/order" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + request.httpBody = try self.encoder.encode(order) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return PlaceOrderError.code400Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode(Order.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/UserAPI.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/UserAPI.swift new file mode 100644 index 000000000000..f2e7504053c9 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/APIs/UserAPI.swift @@ -0,0 +1,401 @@ +// +// UserAPI.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech + +import Foundation +import Combine +import OpenAPITransport + + +open class UserAPI { + private let transport: OpenAPITransport + public var encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(OpenISO8601DateFormatter()) + return encoder + }() + public var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(OpenISO8601DateFormatter()) + return decoder + }() + public var baseURL = URL(string: "http://petstore.swagger.io/v2") + + public init(_ transport: OpenAPITransport) { + self.transport = transport + } + + + /// Create user + /// - POST /user + /// - This can only be done by the logged in user. + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - parameter user: (body) Created user object + /// - returns: AnyPublisher + open func createUser(user: User) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/user" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + request.httpBody = try self.encoder.encode(user) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + + /// Creates list of users with given input array + /// - POST /user/createWithArray + /// - + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - parameter user: (body) List of user object + /// - returns: AnyPublisher + open func createUsersWithArrayInput(user: [User]) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/user/createWithArray" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + request.httpBody = try self.encoder.encode(user) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + + /// Creates list of users with given input array + /// - POST /user/createWithList + /// - + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - parameter user: (body) List of user object + /// - returns: AnyPublisher + open func createUsersWithListInput(user: [User]) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/user/createWithList" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + request.httpBody = try self.encoder.encode(user) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum DeleteUserError: Error, CustomStringConvertible { + // Invalid username supplied + case code400Error + // User not found + case code404Error + + public var description: String { + switch self { + case .code400Error: + return "DeleteUserError: Invalid username supplied" + case .code404Error: + return "DeleteUserError: User not found" + } + } + } + + /// Delete user + /// - DELETE /user/{username} + /// - This can only be done by the logged in user. + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - parameter username: (path) The name that needs to be deleted + /// - returns: AnyPublisher + open func deleteUser(username: String) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/user/{username}" + path = path.replacingOccurrences(of: "{username}", with: username) + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "DELETE" + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return DeleteUserError.code400Error + } + if transportError.statusCode == 404 { + return DeleteUserError.code404Error + } + return transportError + } + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum GetUserByNameError: Error, CustomStringConvertible { + // Invalid username supplied + case code400Error + // User not found + case code404Error + + public var description: String { + switch self { + case .code400Error: + return "GetUserByNameError: Invalid username supplied" + case .code404Error: + return "GetUserByNameError: User not found" + } + } + } + + /// Get user by user name + /// - GET /user/{username} + /// - + /// - parameter username: (path) The name that needs to be fetched. Use user1 for testing. + /// - returns: AnyPublisher + open func getUserByName(username: String) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/user/{username}" + path = path.replacingOccurrences(of: "{username}", with: username) + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return GetUserByNameError.code400Error + } + if transportError.statusCode == 404 { + return GetUserByNameError.code404Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode(User.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum LoginUserError: Error, CustomStringConvertible { + // Invalid username/password supplied + case code400Error + + public var description: String { + switch self { + case .code400Error: + return "LoginUserError: Invalid username/password supplied" + } + } + } + + /// Logs user into the system + /// - GET /user/login + /// - + /// - responseHeaders: [Set-Cookie(String), X-Rate-Limit(Int), X-Expires-After(Date)] + /// - parameter username: (query) The user name for login + /// - parameter password: (query) The password for login in clear text + /// - returns: AnyPublisher + open func loginUser(username: String, password: String) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/user/login" + let url = baseURL.appendingPathComponent(path) + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + var queryItems: [URLQueryItem] = [] + queryItems.append(URLQueryItem(name: "username", value: username)) + queryItems.append(URLQueryItem(name: "password", value: password)) + components?.queryItems = queryItems + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return LoginUserError.code400Error + } + return transportError + } + .tryMap { response in + try self.decoder.decode(String.self, from: response.data) + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + + /// Logs out current logged in user session + /// - GET /user/logout + /// - + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - returns: AnyPublisher + open func logoutUser() -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + let path = "/user/logout" + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + public enum UpdateUserError: Error, CustomStringConvertible { + // Invalid user supplied + case code400Error + // User not found + case code404Error + + public var description: String { + switch self { + case .code400Error: + return "UpdateUserError: Invalid user supplied" + case .code404Error: + return "UpdateUserError: User not found" + } + } + } + + /// Updated user + /// - PUT /user/{username} + /// - This can only be done by the logged in user. + /// - API Key: + /// - type: apiKey api_key + /// - name: api_key + /// - parameter username: (path) name that need to be deleted + /// - parameter user: (body) Updated user object + /// - returns: AnyPublisher + open func updateUser(username: String, user: User) -> AnyPublisher { + Deferred { + Result { + guard let baseURL = self.transport.baseURL ?? self.baseURL else { + throw OpenAPITransportError.badURLError() + } + var path = "/user/{username}" + path = path.replacingOccurrences(of: "{username}", with: username) + let url = baseURL.appendingPathComponent(path) + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + guard let requestURL = components?.url else { + throw OpenAPITransportError.badURLError() + } + var request = URLRequest(url: requestURL) + request.httpMethod = "PUT" + request.httpBody = try self.encoder.encode(user) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + }.publisher + }.flatMap { request -> AnyPublisher in + return self.transport.send(request: request) + .mapError { transportError -> Error in + if transportError.statusCode == 400 { + return UpdateUserError.code400Error + } + if transportError.statusCode == 404 { + return UpdateUserError.code404Error + } + return transportError + } + .tryMap { response in + return () + } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/ApiResponse.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/ApiResponse.swift new file mode 100644 index 000000000000..6aca7a2cca29 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/ApiResponse.swift @@ -0,0 +1,21 @@ +// +// ApiResponse.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +/// Describes the result of uploading an image resource +public struct ApiResponse: Codable { + public var code: Int? + public var type: String? + public var message: String? + + public init(code: Int? = nil, type: String? = nil, message: String? = nil) { + self.code = code + self.type = type + self.message = message + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Category.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Category.swift new file mode 100644 index 000000000000..23177663e242 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Category.swift @@ -0,0 +1,19 @@ +// +// Category.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +/// A category for a pet +public struct Category: Codable { + public var id: Int64? + public var name: String? + + public init(id: Int64? = nil, name: String? = nil) { + self.id = id + self.name = name + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Order.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Order.swift new file mode 100644 index 000000000000..9a9b4e2bf3b1 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Order.swift @@ -0,0 +1,33 @@ +// +// Order.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +/// An order for a pets from the pet store +public struct Order: Codable { + public enum Status: String, Codable, CaseIterable { + case placed = "placed" + case approved = "approved" + case delivered = "delivered" + } + public var id: Int64? + public var petId: Int64? + public var quantity: Int? + public var shipDate: Date? + /// Order Status + public var status: Status? + public var complete: Bool? = false + + public init(id: Int64? = nil, petId: Int64? = nil, quantity: Int? = nil, shipDate: Date? = nil, status: Status? = nil, complete: Bool? = false) { + self.id = id + self.petId = petId + self.quantity = quantity + self.shipDate = shipDate + self.status = status + self.complete = complete + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Pet.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Pet.swift new file mode 100644 index 000000000000..3a3833c751e6 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Pet.swift @@ -0,0 +1,33 @@ +// +// Pet.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +/// A pet for sale in the pet store +public struct Pet: Codable { + public enum Status: String, Codable, CaseIterable { + case available = "available" + case pending = "pending" + case sold = "sold" + } + public var id: Int64? + public var category: Category? + public var name: String + public var photoUrls: [String] + public var tags: [Tag]? + /// pet status in the store + public var status: Status? + + public init(id: Int64? = nil, category: Category? = nil, name: String, photoUrls: [String], tags: [Tag]? = nil, status: Status? = nil) { + self.id = id + self.category = category + self.name = name + self.photoUrls = photoUrls + self.tags = tags + self.status = status + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Tag.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Tag.swift new file mode 100644 index 000000000000..e3e878a4f703 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/Tag.swift @@ -0,0 +1,19 @@ +// +// Tag.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +/// A tag for a pet +public struct Tag: Codable { + public var id: Int64? + public var name: String? + + public init(id: Int64? = nil, name: String? = nil) { + self.id = id + self.name = name + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/User.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/User.swift new file mode 100644 index 000000000000..679ca0d472b2 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Models/User.swift @@ -0,0 +1,32 @@ +// +// User.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +/// A User who is purchasing from the pet store +public struct User: Codable { + public var id: Int64? + public var username: String? + public var firstName: String? + public var lastName: String? + public var email: String? + public var password: String? + public var phone: String? + /// User Status + public var userStatus: Int? + + public init(id: Int64? = nil, username: String? = nil, firstName: String? = nil, lastName: String? = nil, email: String? = nil, password: String? = nil, phone: String? = nil, userStatus: Int? = nil) { + self.id = id + self.username = username + self.firstName = firstName + self.lastName = lastName + self.email = email + self.password = password + self.phone = phone + self.userStatus = userStatus + } +} diff --git a/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Private/OpenISO8601DateFormatter.swift b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Private/OpenISO8601DateFormatter.swift new file mode 100644 index 000000000000..f6a6fc82b733 --- /dev/null +++ b/samples/client/petstore/swift-combine/client/PetstoreOpenAPI/Sources/Private/OpenISO8601DateFormatter.swift @@ -0,0 +1,46 @@ +// +// OpenISO8601DateFormatter.swift +// +// Generated by openapi-generator +// https://openapi-generator.tech +// + +import Foundation + +// https://stackoverflow.com/a/50281094/976628 +class OpenISO8601DateFormatter: DateFormatter { + static let withoutSeconds: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .iso8601) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + return formatter + }() + + private func setup() { + calendar = Calendar(identifier: .iso8601) + locale = Locale(identifier: "en_US_POSIX") + timeZone = TimeZone(secondsFromGMT: 0) + dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + } + + static var shared = OpenISO8601DateFormatter() + + override init() { + super.init() + setup() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + override func date(from string: String) -> Date? { + if let result = super.date(from: string) { + return result + } + return OpenISO8601DateFormatter.withoutSeconds.date(from: string) + } +} diff --git a/samples/client/petstore/swift-combine/swift-combine_test_all.sh b/samples/client/petstore/swift-combine/swift-combine_test_all.sh new file mode 100755 index 000000000000..c3ff4ec160d6 --- /dev/null +++ b/samples/client/petstore/swift-combine/swift-combine_test_all.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +DIRECTORY=`dirname $0` + +(cd $DIRECTORY/tests ; xcodebuild -scheme TestClientTests-Package test -destination "platform=iOS Simulator,name=iPhone 14,OS=latest" | xcpretty && exit ${PIPESTATUS[0]}) + diff --git a/samples/client/petstore/swift-combine/tests/Package.swift b/samples/client/petstore/swift-combine/tests/Package.swift new file mode 100644 index 000000000000..9dbe7b4e42fc --- /dev/null +++ b/samples/client/petstore/swift-combine/tests/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:5.1 + +import PackageDescription + +let package = Package( + name: "TestClientTests", + platforms: [ + .iOS(.v13), + .macOS(.v10_15) + ], + products: [], + dependencies: [.package(path: "../client/PetstoreOpenAPI")], + targets: [ + .testTarget( + name: "TestClientTests", + dependencies: [.byName(name: "PetstoreOpenAPI")] + ), + ] +) diff --git a/samples/client/petstore/swift-combine/tests/Sources/TestClientTests/PetAPITests.swift b/samples/client/petstore/swift-combine/tests/Sources/TestClientTests/PetAPITests.swift new file mode 100644 index 000000000000..3f1314a4dc9d --- /dev/null +++ b/samples/client/petstore/swift-combine/tests/Sources/TestClientTests/PetAPITests.swift @@ -0,0 +1,118 @@ +// +// PetAPITests.swift +// +// +// Created by Anton Davydov on 16.11.2021. +// + +import XCTest +import Combine +import PetstoreOpenAPI +import OpenAPITransport + +class PetAPITests: XCTestCase { + var cancellable = Set() + let timeout: TimeInterval = 10 + let baseURL = URL(string: "https://petstore.swagger.io/v2")! + + override func tearDown() { + cancellable.removeAll() + } + + func testAddPet() { + // Given + let transport = URLSessionOpenAPITransport(config: .init(baseURL: baseURL)) + + let api = PetAPI(transport) + let category = Category(id: 1, name: "CategoryName") + let photoUrls = ["https://petstore.com/sample/photo1.jpg", "https://petstore.com/sample/photo2.jpg"] + let tags = [Tag(id: 10, name: "Tag1"), Tag(id: 11, name: "Tag2")] + let pet = Pet( + id: 100, + category: category, + name: "PetName100", + photoUrls: photoUrls, + tags: tags, + status: .available + ) + + // When + let expectation = expectation(description: "addPetTestExpectation") + api.addPet(pet: pet) + .sink(receiveCompletion: { completion in + // Then + switch completion { + case .finished: + expectation.fulfill() + case let .failure(error): + XCTFail("Adding pet operation finished with error: \(error)") + expectation.fulfill() + } + }, receiveValue: { addedPet in + // Then + XCTAssertTrue(pet == addedPet, "Added pet should be the same as given value") + }) + .store(in: &cancellable) + wait(for: [expectation], timeout: timeout) + } + + func testGetPetByUnknownId() { + // Given + let transport = URLSessionOpenAPITransport(config: .init(baseURL: baseURL)) + let api = PetAPI(transport) + let unknownPetId: Int64 = 1010101010 + + // When + let expectation = expectation(description: "testGetPetByIdExpectation") + api.getPetById(petId: unknownPetId) + .sink { completion in + switch completion { + case .finished: + XCTFail("Finding unknown pet operation should return 404 error") + case let .failure(error): + XCTAssertTrue((error as? PetAPI.GetPetByIdError) == .code404Error, "Finding unknown pet operation should return 404 error") + } + expectation.fulfill() + } receiveValue: { _ in } + .store(in: &cancellable) + + wait(for: [expectation], timeout: timeout) + } + + func testDeleteUnknownPet() { + // Given + let transport = URLSessionOpenAPITransport(config: .init(baseURL: baseURL)) + let api = PetAPI(transport) + let unknownPetId: Int64 = 1010101010 + + // When + let expectation = expectation(description: "testDeletePetExpectation") + api + .deletePet(petId: unknownPetId, apiKey: "special-key") + .sink { completion in + // Then + switch completion { + case .finished: + XCTFail("Deleting unknown pet operation should return 404 error") + expectation.fulfill() + case let .failure(error): + XCTAssertTrue((error as? OpenAPITransportError)?.statusCode == 404, "Deleting unknown pet operation should return 404 error") + expectation.fulfill() + } + } receiveValue: {} + .store(in: &cancellable) + wait(for: [expectation], timeout: timeout) + } +} + +extension Tag: Equatable { + public static func ==(l: Tag, r: Tag) -> Bool { + l.id == r.id && l.name == r.name + } +} + +extension Pet: Equatable { + public static func ==(l: Pet, r: Pet) -> Bool { + l.id == r.id && l.name == r.name && l.photoUrls == r.photoUrls && l.status == r.status && l.tags == r.tags + } +}