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.26k stars 91 forks source link

Support cookie parameters #38

Open czechboy0 opened 1 year ago

czechboy0 commented 1 year ago

There is some existing support as part of parameters, but we should add an example into the reference test, and ensure the usage is ergonomic.

https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#parameter-locations

sliemeobn commented 11 months ago

There is some existing support as part of parameters

At least on the server side I could not find a way to access cookie parameters in the generated request handlers. I can probably work my way around this by using some TaskLocal construct via a middleware, but it sure would be nice to just have typed access in the route handler (or maybe just access to the "raw" request for any not implemented feature yet?)

Use case: A refresh-token endpoint that uses a httpOnly cookie for the actual refresh token (so it's just for that one route).

simonjbeaumont commented 11 months ago

@sliemeobn thanks for adding your feedback here. It's very useful as we want to prioritise the features based on demand from the community.

This shouldn't be too challenging if someone wants to pick it up, as it will likely be similar to other parameters.

but it sure would be nice to just have typed access in the route handler (or maybe just access to the "raw" request for any not implemented feature yet?)

I don't think we've ever discussed adding an escape hatch because things can usually be worked around in middlewares (as you noted), but maybe we could consider it (would be underscored and subject to removal).

What do you think such an API would look like?

Currently the APIProtocol declares the operations as functions from the Input to Output type and has no knowledge of the HTTP request or response. This protocol is used on the client and server sides. The ServerTransport obviously has access to more information, but it's not clear how we'd get that to the "user function" for the handler implementation, without breaking the symmetry of protocol use on client and server.

czechboy0 commented 11 months ago

To help us all iterate here, @sliemeobn could you paste a short snippet of the cookie parameter definition in YAML here? We can then discuss what the generated code should look like on the client and server.

sliemeobn commented 11 months ago

Here is the OpenApi snippet of the route I was referring to (I am setting the cookie in a /login route)

  /refresh-token:
    post:
      operationId: refreshToken
      description: Returns a new access token and optionally changes the station. (for renewal or station change)
      parameters:
        - in: cookie
          name: refreshtoken
          schema: { type: string }

      requestBody:
        required: true
        content:
          application/json: 
            schema:
              type: object
              required: 
               - station
              properties:
                station: { type: string }
czechboy0 commented 11 months ago

I am setting the cookie in a /login route

Can you elaborate on that? Are you saying that the call sequence is the following?

  1. Call /login route with some other auth method, get back a refresh token that you store locally
  2. Call /refresh-token with an optional refreshtoken cookie (what happens if it's not specified?) using the token from the previous call, possibly getting back another cookie value? Is that cookie coming back documented in the OpenAPI doc?
sliemeobn commented 11 months ago

The /login just takes user/password (and station, application specific stuff) as a payload. It returns a short-lived token (for API access) and a Set-Cookie: refershtoken=xxx for renewal (refreshToken with longer expiry).

The application will then renew the access token without requiring a password. Obviously, it does not have to be done that way, but a scoped httpOnly cookie is quite a good place to store this.

  /login:
    post:
      operationId: login
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/Login"} }
      responses:
        "200":
          description: Success
          headers:
            Set-Cookie: { schema: { type: string } }
          content:
            application/json: { schema: { $ref: "#/components/schemas/TokenResponse" }}
        "401":
          description: Unauthorized
czechboy0 commented 11 months ago

Gotcha, and the /refresh-token response also has the following?

headers:
    Set-Cookie: { schema: { type: string } }

Just trying to understand the invocation, and how much could be generated and shared between all users of cookies, and how much we should instead put into middlewares 🙂

sliemeobn commented 11 months ago

At the moment the refreshtoken is only issued on /login - when it expires, a password is needed. So /refresh-token will not set a new one.

sliemeobn commented 11 months ago

About the "escape hatch", I am currently playing around with this:

/// Extensions to work around current limitiation on OpenAPI generator
enum RawRequest {
    @TaskLocal static var current: OpenAPIRuntime.Request?
}

/// Make RawRequest.current available as a task-local.
struct RawRequestMiddleware: ServerMiddleware {
    public func intercept(_ request: OpenAPIRuntime.Request, metadata: OpenAPIRuntime.ServerRequestMetadata, operationID _: String, next: (OpenAPIRuntime.Request, OpenAPIRuntime.ServerRequestMetadata) async throws -> OpenAPIRuntime.Response) async throws -> OpenAPIRuntime.Response {
        try await RawRequest.$current.withValue(request) { try await next(request, metadata) }
    }
}

I general, I was thinking about how most web server frameworks provide some form of "storage" on their request structure for attaching all sorts of things and passing around this unwieldy thing down the pipe. But as this was all conceived before structured concurrency and TaskLocals, and I quite like the "clean" functional way of the generated handler being all "you can only do what you define in the spec!".

But without storage on the request, it'll be tough to use middleware to "pre-process" requests in some reusable form without TaskLocals... So, not sure if that ends up being any better. 🤔

czechboy0 commented 11 months ago

Right, what you have is roughly what I had in mind.

One difference is that I'd make the middleware extract the value you're interested in, validate it, etc, and then put it into a task local.

If you think about how you'd then test your handler, it'll be easier to inject a specific value instead of having to create a whole request.

Once cookies are supported more properly, I'd expect them to show up on the Input struct, just like all the other parameters.

czechboy0 commented 9 months ago

Adding needs-design as we should probably provide more value than just propagate the values like with headers.

czechboy0 commented 9 months ago

We got a repeated ask for this, and with percent encoding of all headers in 0.2.0 cookies might be even more broken, so pulling into 1.0.

sliemeobn commented 9 months ago

since I just stumbled over the issue of "setting cookies" via a Set-Cookie header, I want to add that a "cookie parameter" feature should also include a proper way for server code to set cookies.

I was using a plain string header Set-Cookie like this:

responses:
        "200":
          description: Success
          headers:
            Set-Cookie: { schema: { type: string } }
          content:
            application/json:
              { schema: { $ref: "#/components/schemas/TokenResponse" } }

and then setting a set cookie string in the application code (like myCookie=something; Foo=Bar; ...)

to my surprise a change to the generator started auto-encoding all header values with URL encoding and broke my API. obviously, browsers do not understand that - so it becomes quite a middleware-involved dance to set a cookie through the generated server stubs ATM.

bonus thought: I am not sold that always auto-URL-encoding all header values does more good than harm, but I can respect the "OpenAPI correctness first" position. so if anyone else has a status-quo-challenging opinion on this, it might deserve it's own issue.

czechboy0 commented 9 months ago

Thanks for the additional detail, @sliemeobn.

Yes, the URI encoding of headers was a bug fixed in 0.2.0, we previously weren't doing that.

However, since we don't currently treat cookies differently to other headers, this likely broke cookies for some adopters.

Potential incremental support:

  1. Support only raw string cookies, not structured ones yet (just emit a diagnostic and skip if encountered).
  2. Add support for structured cookies (might make sense to split into a separate issue).

Also, this is likely related to #37, as security schemes can use cookies for storage.

czechboy0 commented 9 months ago

Note that https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie says:

Encoding: Many implementations perform URL encoding on cookie values. However, this is not required by the RFC specification. The URL encoding does help to satisfy the requirements of the characters allowed for .

I can't tell, however, whether they mean encoding the whole header, or just the <cookie-value> portion.

czechboy0 commented 9 months ago

As a workaround for server implementors running in the same issue as @sliemeobn, here's a server middleware you can add that should strip the percent encoding from any outgoing set-cookie headers:

struct SetCookieUnescapingMiddleware: ServerMiddleware {
    func intercept(
        _ request: Request,
        metadata: ServerRequestMetadata,
        operationID: String,
        next: @Sendable (Request, ServerRequestMetadata) async throws -> Response
    ) async throws -> Response {
        var response = try await next(request, metadata)
        response.headerFields = response.headerFields.map { headerField in
            guard headerField.name.lowercased() == "set-cookie" else {
                return headerField
            }
            var headerField = headerField
            headerField.value = headerField.value.removingPercentEncoding ?? headerField.value
            return headerField
        }
        return response
    }
}

Then don't forget to add it to your handler.registerHandlers(..., middlewares: [SetCookieUnescapingMiddleware()]) call.

sliemeobn commented 9 months ago

in case somebody is interested, I hacked together this workaround for my use case for now:

/// Provides Response.setCookies and attaches all added cookies to the response header
public struct SetCookieMiddleware: ServerMiddleware {

    public init() {}

    public func intercept(_ request: OpenAPIRuntime.Request, metadata: OpenAPIRuntime.ServerRequestMetadata, operationID: String, next: @Sendable (OpenAPIRuntime.Request, OpenAPIRuntime.ServerRequestMetadata) async throws -> OpenAPIRuntime.Response) async throws -> OpenAPIRuntime.Response {
        try await Response.$_setCookies.withValue(.init()) {
            var response = try await next(request, metadata)

            for cookie in Response.setCookies.cookies {
                response.headerFields.append(.init(name: "Set-Cookie", value: cookie.description))
            }

            return response
        }
    }
}

public extension Response {
    /// Storage for cookies to be set on the response
    class Cookies: @unchecked Sendable {
        var _cookies: NIOLockedValueBox<[SLCookie]> = .init([])
        public var cookies: [SLCookie] { _cookies.withLockedValue { $0 }}

        public func add(_ cookie: SLCookie) {
            _cookies.withLockedValue { $0.append(cookie) }
        }
    }

    @TaskLocal fileprivate static var _setCookies: Cookies?

    // work-around until cookies are supported though openapi-generator
    // https://github.com/apple/swift-openapi-generator/issues/38
    /// Collects cookies and send them via the
    static var setCookies: Cookies {
        precondition(Self._setCookies != nil, "Response.setCookies called outside request handling task, make sure SetCookieMiddleware is used!")
        return Self._setCookies!
    }
}

usage in API code:

            Response.setCookies.add(cookieForSettingRefreshToken(refreshtoken))

            return try .ok(.init(
                body: .json(.init(
                    token: jwt.sign(accessToken)
                ))
            ))

where SLCookie is just a struct that deals with cookie encoding (pretty much like HBCookie from hummingbird)