Type aware generic API for request/response model

Sorry for the non descriptive title, but basically I'm looking for guidance on implementing an API for a request/response system, where I want the endpoint, request and response types to be tied up so that it fails to compile if you use the wrong combination.

As an example, I have the following endpoints with their respective request and response types:

enum Endpoint {
  case createUser
  case addTodo
}

where the createUser endpoint (on the backend) accepts a JSON object, and returns another JSON object, that can be modeled by:

struct CreateUserRequest: Encodable {
  let name: String
  let age: Int
}

struct CreateUserResponse: Decodable {
  let userID: String
}

Imagine something similar for addTodo but with AddTodoRequest and AddTodoResponse which also conform to Encodable/Decodable as needed.

What I want is to have a single API for making this request that checks that the endpoint and the expected types are correct at compile time.

What I have right now is this solution:

func call<Request: Encodable, Response: Decodable>(_ typedFunction: TypedFunction<Request, Response>, completion: ((Result<Response, Error>) -> Void)? = nil) {
  let data = // request to data with JSONEncoder
  let task = // send with URLSession and convert data to Response with JSONDecoder {
    // Call completion with typed Result<Response, Error> depending on http response.
  }
}

struct TypedFunction<Request: Encodable, Response: Decodable> {
  let endpoint: Endpoint
  let request: Request
  init(endpoint: Endpoint, request: Request) {
    self.endpoint = endpoint
    self.request = request
  }
}

// In another file...

struct CreateUserRequest: Encodable { ... }
struct CreateUserResponse: Decodable { ... }

extension TypedFunction {
  static func createUser(
    _ request: CreateUserRequest
  ) -> TypedFunction<CreateUserRequest, CreateUserResponse> {
    return TypedFunction<CreateUserRequest, CreateUserResponse>(
      endpoint: .createUser, 
      request: request
    )
  }
}

which allows me to do:

Executor.call(.createUser(CreateUserRequest(name..., age...))) {
   print(try! result.get().userID)
}

This is the closest I've gotten without asking for help, but I feel like this must be a very common pattern with some name which I've yet to encounter, and also wondering if there are better architectures for this type of solution.

Thanks for any feedback!

Do you need the enum of the various endpoints? Another approach would be to use a protocol to bind the request and response types together. This makes your call function easier to read, IMO:

protocol ApiEndpoint {
    associatedtype Request: Encodable
    associatedtype Response: Decodable

    // This could also be a function that returns a URLRequest,
    // depending on where you want that logic to live.
    var requestBody: Request { get }

    // Add whatever properties you need here — URL/path, HTTP method, etc
}

// This doesn't have to use async-await. I did it that way for clarity's sake.
// You can implement this will completion handlers and/or `Result` as well.
func call<Endpoint: ApiEndpoint>(_ endpoint: Endpoint) async throws -> Endpoint.Response {
    let request = // Build the URLRequest. Use a JSONEncoder to encode `endpoint.requestBody`.
    let (data, _response) = try await session.data(for: request, delegate: nil)

    return try jsonDecoder.decode(Endpoint.Response.self, from: data)
}

The consumer side would then look like this:

// You could use extensions or initializers to hide some of the details here if you wanted —
// e.g. Executor.createUser(name: ..., age: ...) or CreateUserEndpoint(name: ..., age: ...)
let endpoint = CreateUserEndpoint(requestBody: .init(name: ..., age: ...))
let response = try await Executor.call(endpoint)
print(response.userID)
1 Like

Thanks! I had tried with associated types before buy wasn't successful, this is a great example on how to use it.