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.21k stars 87 forks source link

How to throw errors that won't get transformed to ServerError? #545

Open cantbetrue opened 2 months ago

cantbetrue commented 2 months ago

Motivation

Is there anyway I can throw errors without being converted to an ServerError in Vapor project? What's the easiest way to do it for every APIProtocol function?

I want to use this library to generate code, but it limits how I throw errors, it's frustrating

Proposed solution

Maybe put the conversion to ServerError and ClientError to a middleware and give users the choice whether to use it?

Alternatives considered

No response

Additional information

No response

czechboy0 commented 2 months ago

Hi @cantbetrue,

throwing a ServerError is part of the API contract, providing context that's important for debugging.

If you want to get access to the original underlying error, for example thrown in your handler, use the underlyingError property.

Does that address your issue?

cantbetrue commented 2 months ago

So in a scenario like this, how can I throw ValidationsError directly?

struct API: APIProtocol {
    func register(_ input: Operations.register.Input) async throws -> Operations.register.Output {
        try Components.Schemas.RegisterInput.validate(content: request)
        ...
     }
 }
czechboy0 commented 2 months ago

Which module defines ValidationsError?

You can throw any errors that are convenient to you in the APIProtocol handlers. By default, those get wrapped in ServerError, and then how the error is transformed into an HTTP response depends on the specific ServerTransport (so swift-openapi-vapor, in your case, which is maintained by the Vapor team).

If you'd like to transform all thrown errors into other errors, you can create a ServerMiddleware that looks like this:

struct ErrorMappingMiddleware: ServerMiddleware {
    func intercept(
        _ request: HTTPRequest,
        body: HTTPBody?,
        metadata: ServerRequestMetadata,
        operationID: String,
        next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?)
    ) async throws -> (HTTPResponse, HTTPBody?) {
        do {
          return try await next(request, body, metadata)
        } catch {
          if let error = error as? ServerError {
            let transformedError = error.underlyingError // here turn the error you threw in your handler into another error
            throw transformedError
          } else {
            let transformedError = error // here turn other errors into your transformed errors
            throw transformedError
          }
        }
    }
}

Edit: an end-to-end example of how to use ServerMiddleware: https://github.com/apple/swift-openapi-generator/tree/main/Examples/auth-server-middleware-example

cantbetrue commented 2 months ago

ValidationsError is defined in Vapor.

Anyway, I added your ErrorMappingMiddleware() code, called it like this:

    let myMiddlewares = [ErrorMappingMiddleware()]
    let transport = VaporTransport(routesBuilder: app)

    try API().registerHandlers(on: transport, serverURL: Servers.server1(), middlewares: myMiddlewares) 

But still errors in that function was thrown as ServerError, I get status code 500 and this as the response:

{
    "error": true,
    "reason": "Server error - cause description: 'Middleware of type 'ErrorMappingMiddleware' threw an error.', underlying error: The operation couldn’t be completed. (Vapor.ValidationsError error 1.), operationID: ..."
}

Even if the APIProtocol's function is changed to:

func register(_ input: Operations.register.Input) async throws -> Operations.register.Output {
        throw Abort(.badRequest, reason: "arbitrary test")
 }

Still get back 500 status code and all the "Additional info" in error description.

I tried to extend ServerError and define description myself to make it to my liking but it didn't work, the one defined in the swift-openapi-runtime always take precedence.

Before, in vanilla Vapor, I can easily throw an error as a failed response, with the customized status code and purely cusomizable description, now adding openapi generator makes all that harder.

czechboy0 commented 2 months ago

Can you provide the implementation of the middleware? How are you transforming the errors? That's what affects the responses you're seeing.

cantbetrue commented 2 months ago

I pasted in the above ErrorMappingMiddleware without changing anything, then in entrypoint.swift file

import Vapor
import Logging
import OpenAPIVapor

@main
enum Entrypoint {
    static func main() async throws {
        var env = try Environment.detect()

        try configLogger(env: &env)

        let app = Application(env)
        let transport = VaporTransport(routesBuilder: app)

        try API().registerHandlers(on: transport, serverURL: Servers.server1(), middlewares: [ErrorMappingMiddleware()])
        defer { app.shutdown() }

        do {
            try await configure(app)
        } catch {
            app.logger.report(error: error)
            throw error
        }
        try await app.execute()
    }
}

Then in the API() struct, the function does nothing but throw an AbortError defined by Vapor https://docs.vapor.codes/basics/errors/?h=abort#abort-error.

func register(_ input: Operations.register.Input) async throws -> Operations.register.Output {
        throw Abort(.badRequest, reason: "test")
}

And the response:

image
czechboy0 commented 2 months ago

~In the snippet I provided, see the lines // here turn the error into... - those are the lines where you need to transform your errors into whatever you want them to be 🙂 It was meant to be a snippet that you modify.~

Edit: see below.

czechboy0 commented 2 months ago

@cantbetrue Actually, I just played with this a bit more and realized that while the middleware unwraps the handler's ServerError, the rethrown error gets wrapped again as "middleware threw an error" (because only ServerErrors are passed up without being wrapped).

What this means is that to achieve what you want, you'll need to turn the ErrorMappingMiddleware from being an OpenAPIRuntime.ServerMiddleware into a Vapor middleware. That will be called after both the handler and all ServerMiddlewares have been run, and Vapor shouldn't wrap it again - so that's where you can do the final customization before being turned into an HTTP response.

czechboy0 commented 2 months ago

Ok, confirmed that this does what you want:

import OpenAPIRuntime
import OpenAPIVapor
import Vapor

struct Handler: APIProtocol {
    func getGreeting(_ input: Operations.getGreeting.Input) async throws -> Operations.getGreeting.Output {
        throw Abort(.badRequest, reason: "arbitrary test")
    }
}

struct ErrorMappingMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        do {
            return try await next.respond(to: request)
        } catch {
            if let error = error as? ServerError {
                let transformedError = error.underlyingError // here turn the error you threw in your handler into another error
                throw transformedError
            } else {
                let transformedError = error // here turn other errors into your transformed errors
                throw transformedError
            }
        }
    }
}

@main struct HelloWorldVaporServer {
    static func main() async throws {
        let app = Vapor.Application()
        app.middleware.use(ErrorMappingMiddleware())
        let transport = VaporTransport(routesBuilder: app)
        let handler = Handler()
        try handler.registerHandlers(on: transport, serverURL: URL(string: "/api")!)
        try await app.execute()
    }
}