Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
cantbetrue opened this issue Mar 13, 2024 · 9 comments
Open

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

cantbetrue opened this issue Mar 13, 2024 · 9 comments
Labels
kind/feature New feature. status/triage Collecting information required to triage the issue.

Comments

@cantbetrue
Copy link

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

@cantbetrue cantbetrue added kind/feature New feature. status/triage Collecting information required to triage the issue. labels Mar 13, 2024
@czechboy0
Copy link
Collaborator

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
Copy link
Author

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
Copy link
Collaborator

czechboy0 commented Mar 13, 2024

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
Copy link
Author

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
Copy link
Collaborator

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

@cantbetrue
Copy link
Author

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
Copy link
Collaborator

czechboy0 commented Mar 14, 2024

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
Copy link
Collaborator

@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
Copy link
Collaborator

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()
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
kind/feature New feature. status/triage Collecting information required to triage the issue.
Projects
None yet
Development

No branches or pull requests

2 participants