diff --git a/README.md b/README.md index b6717c1231..43b131746c 100644 --- a/README.md +++ b/README.md @@ -61,26 +61,11 @@ The App Store Connect API requires authentication using API keys. You'll need to 1. Create an API key in App Store Connect 2. Generate a signed JWT token using your key ID, issuer ID, and private key -3. Inject the token into each request via an OpenAPI middleware +3. Pass the token to the built-in `BearerAuthMiddleware` when creating the client ```swift -import OpenAPIRuntime - -struct BearerAuthMiddleware: ClientMiddleware { - let token: String - - func intercept( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String, - next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) - ) async throws -> (HTTPResponse, HTTPBody?) { - var request = request - request.headerFields[.authorization] = "Bearer \(token)" - return try await next(request, body, baseURL) - } -} +import ASConnectService +import OpenAPIURLSession let client = Client( serverURL: try Servers.server1(), @@ -89,6 +74,8 @@ let client = Client( ) ``` +The `BearerAuthMiddleware` automatically injects the `Authorization` header with the Bearer token into every outgoing request. + ## Supported Platforms - iOS 13.0+ diff --git a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md index 56df742bcf..8763b036bd 100644 --- a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md +++ b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md @@ -59,26 +59,11 @@ The App Store Connect API requires authentication using API keys. You'll need to 1. Create an API key in App Store Connect 2. Generate a signed JWT token using your key ID, issuer ID, and private key -3. Inject the token into each request via an OpenAPI middleware +3. Pass the token to the built-in ``BearerAuthMiddleware`` when creating the client ```swift -import OpenAPIRuntime - -struct BearerAuthMiddleware: ClientMiddleware { - let token: String - - func intercept( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String, - next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) - ) async throws -> (HTTPResponse, HTTPBody?) { - var request = request - request.headerFields[.authorization] = "Bearer \(token)" - return try await next(request, body, baseURL) - } -} +import ASConnectService +import OpenAPIURLSession let client = Client( serverURL: try Servers.server1(), @@ -87,6 +72,8 @@ let client = Client( ) ``` +The ``BearerAuthMiddleware`` automatically injects the `Authorization` header with the Bearer token into every outgoing request. + ## Supported Platforms - iOS 13.0+ @@ -108,6 +95,10 @@ let client = Client( - ``Client``: The main API client for making requests +### Authentication + +- ``BearerAuthMiddleware``: A client middleware that injects a Bearer token into outgoing HTTP requests + ### API Endpoints The package provides access to all App Store Connect API endpoints including: diff --git a/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift b/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift new file mode 100644 index 0000000000..2446ab7365 --- /dev/null +++ b/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift @@ -0,0 +1,80 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the ASConnectService open source project +// +// Copyright (c) 2026 Röck+Cöde VoF. and the ASConnectService project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of ASConnectService project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import struct Foundation.URL +import struct Foundation.URLComponents +import struct Foundation.URLQueryItem +import struct HTTPTypes.HTTPField +import struct HTTPTypes.HTTPFields +import struct HTTPTypes.HTTPRequest +import struct HTTPTypes.HTTPResponse +import protocol OpenAPIRuntime.ClientMiddleware +import class OpenAPIRuntime.HTTPBody + +/// A client middleware that injects a Bearer authentication token into outgoing HTTP requests. +/// +/// This middleware appends an `Authorization` header with a Bearer token to every request +/// before forwarding it to the next handler in the middleware chain. It is intended for use +/// with the App Store Connect API, which requires JSON Web Token (JWT) authentication. +/// +/// ## Usage +/// +/// ```swift +/// let middleware = BearerAuthMiddleware(token: "your-jwt-token") +/// ``` +public struct BearerAuthMiddleware { + // MARK: Properties + + /// The Bearer token to include in the `Authorization` header of each request. + private let token: String + + // MARK: Initializers + + /// Creates a new middleware instance with the given Bearer token. + /// - Parameter token: A JSON Web Token (JWT) string used to authenticate requests to the App Store Connect API. + init(token: String) { + self.token = token + } +} + +// MARK: - ClientMiddleware + +extension BearerAuthMiddleware: ClientMiddleware { + // MARK: Methods + + /// Intercepts an outgoing HTTP request and adds a Bearer authentication token to its headers. + /// - Parameters: + /// - request: The original HTTP request. + /// - body: The optional body of the request. + /// - baseURL: The base URL for the request. + /// - operationID: The identifier of the API operation being performed. + /// - next: The next handler in the middleware chain. + /// - Returns: The HTTP response and optional body returned by the next handler. + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var request = request + request.headerFields[.authorization] = "Bearer \(token)" + + return try await next( + request, + body, + baseURL + ) + } +} diff --git a/Tests/ASConnectService/ASConnectServiceTests.swift b/Tests/ASConnectService/ASConnectServiceTests.swift deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/Tests/ASConnectService/Cases/Public/Middlewares/BearerAuthMiddlewareTests.swift b/Tests/ASConnectService/Cases/Public/Middlewares/BearerAuthMiddlewareTests.swift new file mode 100644 index 0000000000..222aa98897 --- /dev/null +++ b/Tests/ASConnectService/Cases/Public/Middlewares/BearerAuthMiddlewareTests.swift @@ -0,0 +1,140 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing + +@testable import ASConnectService + +@Suite("BearerAuthMiddleware") +struct BearerAuthMiddlewareTests { + // MARK: Tests + + @Test("Adds the Authorization header with the Bearer token to the request") + func addsAuthorizationHeader() async throws { + let middleware = BearerAuthMiddleware(token: .Token.jwt) + + let (response, _) = try await middleware.intercept( + .get, + body: nil, + baseURL: .base, + operationID: "listApps", + next: { interceptedRequest, body, baseURL in + #expect(interceptedRequest.headerFields[.authorization] == "Bearer test-jwt-token") + return (HTTPResponse(status: .ok), nil) + } + ) + + #expect(response.status == .ok) + } + + @Test("Forwards the request body to the next handler") + func forwardsRequestBody() async throws { + let middleware = BearerAuthMiddleware(token: .Token.jwt) + let requestBody: HTTPBody = HTTPBody("request-body") + + var receivedBody: HTTPBody? + + _ = try await middleware.intercept( + .get, + body: requestBody, + baseURL: .base, + operationID: "createApp", + next: { _, body, _ in + receivedBody = body + + return (HTTPResponse(status: .created), nil) + } + ) + + let bodyData = try await Data(collecting: try #require(receivedBody), upTo: .max) + + #expect(bodyData == Data("request-body".utf8)) + } + + @Test("Forwards the base URL to the next handler") + func forwardsBaseURL() async throws { + let middleware = BearerAuthMiddleware(token: .Token.jwt) + let expectedBaseURL = URL.base + + _ = try await middleware.intercept( + .get, + body: nil, + baseURL: expectedBaseURL, + operationID: "listApps", + next: { _, _, baseURL in + #expect(baseURL == expectedBaseURL) + + return (HTTPResponse(status: .ok), nil) + } + ) + } + + @Test("Returns the response from the next handler") + func returnsNextHandlerResponse() async throws { + let middleware = BearerAuthMiddleware(token: .Token.jwt) + let expectedBody: HTTPBody = HTTPBody("response-body") + + let (response, body) = try await middleware.intercept( + .get, + body: nil, + baseURL: .base, + operationID: "listApps", + next: { _, _, _ in + (HTTPResponse(status: .notFound), expectedBody) + } + ) + + #expect(response.status == .notFound) + + let bodyData = try await Data(collecting: try #require(body), upTo: .max) + + #expect(bodyData == Data("response-body".utf8)) + } + + @Test("Preserves existing request headers") + func preservesExistingHeaders() async throws { + let middleware = BearerAuthMiddleware(token: .Token.jwt) + + var request = HTTPRequest.get + + request.headerFields[.contentType] = "application/json" + + _ = try await middleware.intercept( + request, + body: nil, + baseURL: .base, + operationID: "listApps", + next: { interceptedRequest, _, _ in + #expect(interceptedRequest.headerFields[.contentType] == "application/json") + #expect(interceptedRequest.headerFields[.authorization] == "Bearer \(String.Token.jwt)") + + return (HTTPResponse(status: .ok), nil) + } + ) + } +} + +// MARK: - HTTPRequest+Samples + +private extension HTTPRequest { + static let get = HTTPRequest( + method: .get, + scheme: "https", + authority: "api.appstoreconnect.apple.com", + path: "/v1/apps" + ) +} + +// MARK: - String+Samples + +private extension String { + enum Token { + static let jwt: String = "test-jwt-token" + } +} + +// MARK: - URL+Samples + +private extension URL { + static let base = URL(string: "https://api.appstoreconnect.apple.com")! +}