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

A way to group features or services to allow middleware to be applied to groups #551

Open dannflor opened 1 month ago

dannflor commented 1 month ago

Motivation

Using the OpenAPI generator loses a lot of expressivity on the server side. Once you're in generated-code-land, you lose access to the functionality you would be able to have with vanilla routes e.g. applying middleware to groups.

/// Before
final class AdminMiddleware: AsyncMiddleware
{
    func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
        if request.auth.has(User.self) {
            if try await (UserService(request.db).user(from: request.auth).isAdmin {
                return try await next.respond(to: request)
            }
        }
        throw Abort(.forbidden, reason: "Admins only, fools")
    }
}
let adminProtected = app.grouped(AdminMiddleware())
adminProtected.group("api", configure: apiController)
func apiController(api: RoutesBuilder) {
    api.get("users") { req async throws -> [User] in
        try await UserService(req.db).allUsers
    }
    api.delete("user", ":name") { req async throws -> User in
        if let username = req.parameters.get("name") {
            try await UserService(req.db).deleteUser(username)
        } else { throw Abort(.badRequest) }
    }
}

/// After
struct APIProtocolImpl: APIProtocol {
    // I am using a middleware with swift-dependency to have access to the request
    @Dependency(\.request) var req

    func getUsers(_ input: Operations.getUsers.Input) async throws -> Operations.getUsers.Output {
        // I would need to copy this block for every single route that would be behind AdminMiddleware
        guard try await req.auth.has(User.self), (UserService(req.db).user(from: req.auth).isAdmin else {
            return  .forbidden(.init("Admins only, fools"))
        }
        return try await .ok(.init(body: .json(
            .init(UserService(req.db).allUsers)
        )))
    }
}

Repeating the middleware logic for every single route that should be behind it can quickly grow completely untenable if you have middleware that is supposed to be in front of dozens or hundreds of endpoints. However you can apply middleware to the routesBuilder you pass to the transport:

let transport = VaporTransport(
    routesBuilder: app.grouped(OpenAPIRequestInjectionMiddleware()).grouped(AdminMiddleware())
)

But we're artificially limited to one document, which means I can only apply a middleware if I'm willing to put it in front of all routes. I've heard that this project intends to implement authorization as part of the API spec, which would alleviate the problem in this specific example, but this isn't the only situation I would want to arbitrarily group routes for a middleware.

Proposed solution

Leverage the openapi-generator-config.yaml configuration file to generate code from multiple documents rather than just one. Let us supply a list of document names that will each generate a protocol with associated types. I envision it looking something like this:

generate:
    - types
    - server
documents:
    - api/admin.yaml
    - api/public.yaml

This config would look for an api/admin.yaml and an api/public.yaml document in the target. It would then generate two protocols: AdminAPIProtocol and PublicAPIProtocol. We could then do something like:

let requestAttached = app.grouped(OpenAPIRequestInjectionMiddleware())

let adminTransport = VaporTransport(routesBuilder: requestAttached.grouped(AdminMiddleware()))
let publicTransport = VaporTransport(routesBuilder: requestAttached)

let adminHandler = AdminAPIProtocolImpl()
let publicHandler = PublicAPIProtocolImpl()

try adminHandler.registerHandlers(on: adminTransport, serverURL: Servers.server1())
try publicHandler.registerHandlers(on: publicTransport, serverURL: Servers.server1())

try await app.execute()

If the documents list isn't specified in the config, we could default to the normal behavior of looking for an openapi.yaml document and generating a single protocol from that.

Alternatives considered

Ideally, we'd be able to integrate with some unified serverside middleware framework to let us specify middleware in generated-code-land. SSWG is supposed to be working on one, but the repo isn't even public, much less finished. In the meantime, the requirement to run middleware operations on every single route is extremely onerous and this seems like a major blocker to migrating large applications to use it.

Additional information

No response

dannflor commented 1 month ago

I am aware that middleware could respond in a way that is undocumented, however that was already the case with whatever middleware is placed in front of the single route builder (for instance the request injection middleware could respond with an undocumented response if it pleased).

czechboy0 commented 1 month ago

If I understood your ask correctly, you can already achieve this by putting each document into a separate Swift module, and then having your executable attach the generated routes from both OpenAPI docs to the same server.

That would allow you to do exactly this, with two extra imports:

import Admin
import Public

let requestAttached = app.grouped(OpenAPIRequestInjectionMiddleware())

let adminTransport = VaporTransport(routesBuilder: requestAttached.grouped(AdminMiddleware()))
let publicTransport = VaporTransport(routesBuilder: requestAttached)

let adminHandler = AdminAPIProtocolImpl()
let publicHandler = PublicAPIProtocolImpl()

try adminHandler.registerHandlers(on: adminTransport, serverURL: Servers.server1())
try publicHandler.registerHandlers(on: publicTransport, serverURL: Servers.server1())

try await app.execute()
dannflor commented 1 month ago

Thanks for the workaround. Are there any plans to more cleanly support middleware, possibly by allowing the middleware to be documented and have their responses enforced by some sort of protocol as well?

czechboy0 commented 1 month ago

Middlewares are a concept of the runtime library, which is shared by all adopters with their OpenAPI docs.

Maybe there could be a concept of a project-specific middleware, but we haven't done any thinking around this area.

Ideas and proposals are always welcome 🙂