apple / swift-openapi-generator

Generate Swift client and server code from an OpenAPI document.
https://swiftpackageindex.com/apple/swift-openapi-generator/documentation
Apache License 2.0
1.43k stars 116 forks source link

Hyphen in path variable name results in static path variable name in url #601

Closed ChipCracker closed 3 months ago

ChipCracker commented 3 months ago

Description

When generating Swift code from the API spec, path variables that contain a “-” are not recognized as path variables and are instead generated as fixed URL parameters/paths. However the path variable is generated correctly, its just not used:

Example:

/testresult/upload/{qrcode-uuid}:

Becomes (Client.swift):

let path = try converter.renderedPath(
    template: "/testresult/upload/qrcode-uuid",
    parameters: []
)

However, the expected behavior would be:

let path = try converter.renderedPath(
    template: "/testresult/upload/{}",
    parameters: [
        input.path.qrcode_hyphen_uuid
    ]
)

Types.swift:

/// - Remark: Generated from `#/paths/testresult/{qrcode-uuid}/GET/path`.
public struct Path: Sendable, Hashable {
    /// UUID associated with the specific test result
    ///
    /// - Remark: Generated from `#/paths/testresult/{qrcode-uuid}/GET/path/qrcode-uuid`.
    public var qrcode_hyphen_uuid: Swift.String
    /// Creates a new `Path`.
    ///
    /// - Parameters:
    ///   - qrcode_hyphen_uuid: UUID associated with the specific test result
    public init(qrcode_hyphen_uuid: Swift.String) {
        self.qrcode_hyphen_uuid = qrcode_hyphen_uuid
    }
}

Reproduction

openapi: 3.0.3
info:
  title: Example
  description: This is the API definition for the example-app
  contact:
    email: example@example.com
  license:
    name: Apache 2.0
    url: http://www.apache.org/licenses/LICENSE-2.0.html
  version: 1.0.11
servers:
  - url: http://localhost:8080/api/v1
tags:
  - name: Test-Result
paths:
  /testresult/{qrcode-uuid}:
    get:
      tags:
        - Test-Result
      summary: "Retrieves a specific test result by UUID as an encrypted ZIP file"
      operationId: "get_test_result"
      x-openapi-router-controller: openapi_server.controllers.test_result_controller
      parameters:
        - in: path
          name: qrcode-uuid
          required: true
          schema:
            type: string
          description: "UUID associated with the specific test result"
      responses:
        '200':
          description: "Successful operation, encrypted test result retrieved"
          content:
            application/zip:
              schema:
                type: string
                format: binary
                description: "Encrypted ZIP file containing the test result"
        '404':
          description: "Test result not found"
        '500':
          description: "Server error"
  /testresult/upload/{qrcodeuuid}:
    put:
      tags:
        - Test-Result
      summary: "Uploads a test result zip file"
      x-openapi-router-controller: openapi_server.controllers.test_result_controller
      parameters:
        - in: path
          name: qrcodeuuid
          required: true
          schema:
            type: string
          description: "UUID associated with the QR code"
        - in: query
          name: study-secret
          required: true
          schema:
            type: string
          description: "the study secret for auth"
      responses:
        '200':
          description: "Successful operation"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiResponse'
        '409':
          description: "Testresult already exists"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiResponse'
      requestBody:
        required: true
        description: "Encrypted ZIP file containing test results"
        content:
          application/zip:
            schema:
              type: string
              format: binary
              description: "Encrypted ZIP file"
  /health:
    get:
      tags:
        - Health Check
      summary: "Health Check Endpoint"
      description: "Returns the health status of the API server"
      operationId: "get_health_status"
      x-openapi-router-controller: openapi_server.controllers.health_check_controller
      responses:
        '200':
          description: "API server is up and running"
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "healthy"
                  timestamp:
                    type: string
                    format: date-time
                    example: "2024-05-29T12:34:56Z"

components:
  schemas:
    ApiResponse:
      type: object
      properties:
        code:
          type: integer
          format: int32
        type:
          type: string
        message:
          type: string
      xml:
        name: ApiResponse

Package version(s)

OpenAPIKit 3.2.0
swift-algorithms 1.2.0
swift-argument-parser 1.5.0
swift-collections 1.1.2
swift-http-types 1.3.0
swift-numerics 1.0.2
swift-openapi-generator 1.2.1
swift-openapi-runtime 1.4.1
swift-openapi-urlsession 1.0.1
Yams 5.1.3

Expected behavior

// Generated by swift-openapi-generator, do not modify.
@_spi(Generated) import OpenAPIRuntime
#if os(Linux)
@preconcurrency import struct Foundation.URL
@preconcurrency import struct Foundation.Data
@preconcurrency import struct Foundation.Date
#else
import struct Foundation.URL
import struct Foundation.Data
import struct Foundation.Date
#endif
import HTTPTypes
/// This is the API definition for the memtest-app
internal struct Client: APIProtocol {
    /// The underlying HTTP client.
    private let client: UniversalClient
    /// Creates a new client.
    /// - Parameters:
    ///   - serverURL: The server URL that the client connects to. Any server
    ///   URLs defined in the OpenAPI document are available as static methods
    ///   on the ``Servers`` type.
    ///   - configuration: A set of configuration values for the client.
    ///   - transport: A transport that performs HTTP operations.
    ///   - middlewares: A list of middlewares to call before the transport.
    internal init(
        serverURL: Foundation.URL,
        configuration: Configuration = .init(),
        transport: any ClientTransport,
        middlewares: [any ClientMiddleware] = []
    ) {
        self.client = .init(
            serverURL: serverURL,
            configuration: configuration,
            transport: transport,
            middlewares: middlewares
        )
    }
    private var converter: Converter {
        client.converter
    }
    /// Retrieves a specific test result by UUID as an encrypted ZIP file
    ///
    /// - Remark: HTTP `GET /testresult/{qrcode-uuid}`.
    /// - Remark: Generated from `#/paths//testresult/{qrcode-uuid}/get(get_test_result)`.
    internal func get_test_result(_ input: Operations.get_test_result.Input) async throws -> Operations.get_test_result.Output {
        try await client.send(
            input: input,
            forOperation: Operations.get_test_result.id,
            serializer: { input in
                let path = try converter.renderedPath(
                    template: "/testresult/{}",
                    parameters: [
                        input.path.qrcode_hyphen_uuid
                    ]
                )
                var request: HTTPTypes.HTTPRequest = .init(
                    soar_path: path,
                    method: .get
                )
                suppressMutabilityWarning(&request)
                converter.setAcceptHeader(
                    in: &request.headerFields,
                    contentTypes: input.headers.accept
                )
                return (request, nil)
            },
            deserializer: { response, responseBody in
                switch response.status.code {
                case 200:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.get_test_result.Output.Ok.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/zip"
                        ]
                    )
                    switch chosenContentType {
                    case "application/zip":
                        body = try converter.getResponseBodyAsBinary(
                            OpenAPIRuntime.HTTPBody.self,
                            from: responseBody,
                            transforming: { value in
                                .application_zip(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .ok(.init(body: body))
                case 404:
                    return .notFound(.init())
                case 500:
                    return .internalServerError(.init())
                default:
                    return .undocumented(
                        statusCode: response.status.code,
                        .init(
                            headerFields: response.headerFields,
                            body: responseBody
                        )
                    )
                }
            }
        )
    }
    /// Uploads a test result zip file
    ///
    /// - Remark: HTTP `PUT /testresult/upload/{qrcodeuuid}`.
    /// - Remark: Generated from `#/paths//testresult/upload/{qrcodeuuid}/put(upload_test_result)`.
    internal func upload_test_result(_ input: Operations.upload_test_result.Input) async throws -> Operations.upload_test_result.Output {
        try await client.send(
            input: input,
            forOperation: Operations.upload_test_result.id,
            serializer: { input in
                let path = try converter.renderedPath(
                    template: "/testresult/upload/{}",
                    parameters: [
                        input.path.qrcode_hyphen_uuid
                    ]
                )
                var request: HTTPTypes.HTTPRequest = .init(
                    soar_path: path,
                    method: .put
                )
                suppressMutabilityWarning(&request)
                try converter.setQueryItemAsURI(
                    in: &request,
                    style: .form,
                    explode: true,
                    name: "study-secret",
                    value: input.query.study_hyphen_secret
                )
                converter.setAcceptHeader(
                    in: &request.headerFields,
                    contentTypes: input.headers.accept
                )
                let body: OpenAPIRuntime.HTTPBody?
                switch input.body {
                case let .application_zip(value):
                    body = try converter.setRequiredRequestBodyAsBinary(
                        value,
                        headerFields: &request.headerFields,
                        contentType: "application/zip"
                    )
                }
                return (request, body)
            },
            deserializer: { response, responseBody in
                switch response.status.code {
                case 200:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.upload_test_result.Output.Ok.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/json"
                        ]
                    )
                    switch chosenContentType {
                    case "application/json":
                        body = try await converter.getResponseBodyAsJSON(
                            Components.Schemas.ApiResponse.self,
                            from: responseBody,
                            transforming: { value in
                                .json(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .ok(.init(body: body))
                case 409:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.upload_test_result.Output.Conflict.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/json"
                        ]
                    )
                    switch chosenContentType {
                    case "application/json":
                        body = try await converter.getResponseBodyAsJSON(
                            Components.Schemas.ApiResponse.self,
                            from: responseBody,
                            transforming: { value in
                                .json(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .conflict(.init(body: body))
                default:
                    return .undocumented(
                        statusCode: response.status.code,
                        .init(
                            headerFields: response.headerFields,
                            body: responseBody
                        )
                    )
                }
            }
        )
    }
    /// Health Check Endpoint
    ///
    /// Returns the health status of the API server
    ///
    /// - Remark: HTTP `GET /health`.
    /// - Remark: Generated from `#/paths//health/get(get_health_status)`.
    internal func get_health_status(_ input: Operations.get_health_status.Input) async throws -> Operations.get_health_status.Output {
        try await client.send(
            input: input,
            forOperation: Operations.get_health_status.id,
            serializer: { input in
                let path = try converter.renderedPath(
                    template: "/health",
                    parameters: []
                )
                var request: HTTPTypes.HTTPRequest = .init(
                    soar_path: path,
                    method: .get
                )
                suppressMutabilityWarning(&request)
                converter.setAcceptHeader(
                    in: &request.headerFields,
                    contentTypes: input.headers.accept
                )
                return (request, nil)
            },
            deserializer: { response, responseBody in
                switch response.status.code {
                case 200:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.get_health_status.Output.Ok.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/json"
                        ]
                    )
                    switch chosenContentType {
                    case "application/json":
                        body = try await converter.getResponseBodyAsJSON(
                            Operations.get_health_status.Output.Ok.Body.jsonPayload.self,
                            from: responseBody,
                            transforming: { value in
                                .json(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .ok(.init(body: body))
                default:
                    return .undocumented(
                        statusCode: response.status.code,
                        .init(
                            headerFields: response.headerFields,
                            body: responseBody
                        )
                    )
                }
            }
        )
    }
}

Environment

Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0

Additional information

Currently generated code:

// Generated by swift-openapi-generator, do not modify.
@_spi(Generated) import OpenAPIRuntime
#if os(Linux)
@preconcurrency import struct Foundation.URL
@preconcurrency import struct Foundation.Data
@preconcurrency import struct Foundation.Date
#else
import struct Foundation.URL
import struct Foundation.Data
import struct Foundation.Date
#endif
import HTTPTypes
/// This is the API definition for the memtest-app
public struct Client: APIProtocol {
    /// The underlying HTTP client.
    private let client: UniversalClient
    /// Creates a new client.
    /// - Parameters:
    ///   - serverURL: The server URL that the client connects to. Any server
    ///   URLs defined in the OpenAPI document are available as static methods
    ///   on the ``Servers`` type.
    ///   - configuration: A set of configuration values for the client.
    ///   - transport: A transport that performs HTTP operations.
    ///   - middlewares: A list of middlewares to call before the transport.
    public init(
        serverURL: Foundation.URL,
        configuration: Configuration = .init(),
        transport: any ClientTransport,
        middlewares: [any ClientMiddleware] = []
    ) {
        self.client = .init(
            serverURL: serverURL,
            configuration: configuration,
            transport: transport,
            middlewares: middlewares
        )
    }
    private var converter: Converter {
        client.converter
    }
    /// Retrieves a specific test result by UUID as an encrypted ZIP file
    ///
    /// - Remark: HTTP `GET /testresult/{qrcode-uuid}`.
    /// - Remark: Generated from `#/paths//testresult/{qrcode-uuid}/get(get_test_result)`.
    public func get_test_result(_ input: Operations.get_test_result.Input) async throws -> Operations.get_test_result.Output {
        try await client.send(
            input: input,
            forOperation: Operations.get_test_result.id,
            serializer: { input in
                let path = try converter.renderedPath(
                    template: "/testresult/qrcode-uuid",
                    parameters: []
                )
                var request: HTTPTypes.HTTPRequest = .init(
                    soar_path: path,
                    method: .get
                )
                suppressMutabilityWarning(&request)
                converter.setAcceptHeader(
                    in: &request.headerFields,
                    contentTypes: input.headers.accept
                )
                return (request, nil)
            },
            deserializer: { response, responseBody in
                switch response.status.code {
                case 200:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.get_test_result.Output.Ok.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/zip"
                        ]
                    )
                    switch chosenContentType {
                    case "application/zip":
                        body = try converter.getResponseBodyAsBinary(
                            OpenAPIRuntime.HTTPBody.self,
                            from: responseBody,
                            transforming: { value in
                                .application_zip(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .ok(.init(body: body))
                case 404:
                    return .notFound(.init())
                case 500:
                    return .internalServerError(.init())
                default:
                    return .undocumented(
                        statusCode: response.status.code,
                        .init(
                            headerFields: response.headerFields,
                            body: responseBody
                        )
                    )
                }
            }
        )
    }
    /// Uploads a test result zip file
    ///
    /// - Remark: HTTP `PUT /testresult/upload/{qrcode-uuid}`.
    /// - Remark: Generated from `#/paths//testresult/upload/{qrcode-uuid}/put(upload_test_result)`.
    public func upload_test_result(_ input: Operations.upload_test_result.Input) async throws -> Operations.upload_test_result.Output {
        try await client.send(
            input: input,
            forOperation: Operations.upload_test_result.id,
            serializer: { input in
                let path = try converter.renderedPath(
                    template: "/testresult/upload/qrcode-uuid",
                    parameters: []
                )
                var request: HTTPTypes.HTTPRequest = .init(
                    soar_path: path,
                    method: .put
                )
                suppressMutabilityWarning(&request)
                try converter.setQueryItemAsURI(
                    in: &request,
                    style: .form,
                    explode: true,
                    name: "study-secret",
                    value: input.query.study_hyphen_secret
                )
                converter.setAcceptHeader(
                    in: &request.headerFields,
                    contentTypes: input.headers.accept
                )
                let body: OpenAPIRuntime.HTTPBody?
                switch input.body {
                case let .application_zip(value):
                    body = try converter.setRequiredRequestBodyAsBinary(
                        value,
                        headerFields: &request.headerFields,
                        contentType: "application/zip"
                    )
                }
                return (request, body)
            },
            deserializer: { response, responseBody in
                switch response.status.code {
                case 200:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.upload_test_result.Output.Ok.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/json"
                        ]
                    )
                    switch chosenContentType {
                    case "application/json":
                        body = try await converter.getResponseBodyAsJSON(
                            Components.Schemas.ApiResponse.self,
                            from: responseBody,
                            transforming: { value in
                                .json(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .ok(.init(body: body))
                case 409:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.upload_test_result.Output.Conflict.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/json"
                        ]
                    )
                    switch chosenContentType {
                    case "application/json":
                        body = try await converter.getResponseBodyAsJSON(
                            Components.Schemas.ApiResponse.self,
                            from: responseBody,
                            transforming: { value in
                                .json(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .conflict(.init(body: body))
                default:
                    return .undocumented(
                        statusCode: response.status.code,
                        .init(
                            headerFields: response.headerFields,
                            body: responseBody
                        )
                    )
                }
            }
        )
    }
    /// Health Check Endpoint
    ///
    /// Returns the health status of the API server
    ///
    /// - Remark: HTTP `GET /health`.
    /// - Remark: Generated from `#/paths//health/get(get_health_status)`.
    public func get_health_status(_ input: Operations.get_health_status.Input) async throws -> Operations.get_health_status.Output {
        try await client.send(
            input: input,
            forOperation: Operations.get_health_status.id,
            serializer: { input in
                let path = try converter.renderedPath(
                    template: "/health",
                    parameters: []
                )
                var request: HTTPTypes.HTTPRequest = .init(
                    soar_path: path,
                    method: .get
                )
                suppressMutabilityWarning(&request)
                converter.setAcceptHeader(
                    in: &request.headerFields,
                    contentTypes: input.headers.accept
                )
                return (request, nil)
            },
            deserializer: { response, responseBody in
                switch response.status.code {
                case 200:
                    let contentType = converter.extractContentTypeIfPresent(in: response.headerFields)
                    let body: Operations.get_health_status.Output.Ok.Body
                    let chosenContentType = try converter.bestContentType(
                        received: contentType,
                        options: [
                            "application/json"
                        ]
                    )
                    switch chosenContentType {
                    case "application/json":
                        body = try await converter.getResponseBodyAsJSON(
                            Operations.get_health_status.Output.Ok.Body.jsonPayload.self,
                            from: responseBody,
                            transforming: { value in
                                .json(value)
                            }
                        )
                    default:
                        preconditionFailure("bestContentType chose an invalid content type.")
                    }
                    return .ok(.init(body: body))
                default:
                    return .undocumented(
                        statusCode: response.status.code,
                        .init(
                            headerFields: response.headerFields,
                            body: responseBody
                        )
                    )
                }
            }
        )
    }
}
czechboy0 commented 3 months ago

Hi @ChipCracker, thank you for this great bug report!

You are correct, the bug is caused by the hyphen in the parameter name. It isn't getting correctly parsed by the regular expression that splits the path into components.

I have a fix ready locally, let me open a PR shortly.

ChipCracker commented 3 months ago

Hi @czechboy0

thank you for the swift response and for preparing a fix so quickly!

czechboy0 commented 3 months ago

You're welcome, it was only possible because you already did the heavy lifting and identified the bug 🙂

czechboy0 commented 3 months ago

Fixed and released in 1.3.0: https://github.com/apple/swift-openapi-generator/releases/tag/1.3.0