Search code examples
swiftgenericsvapor

Making Vapor API response JSON API Spec compliant


I have an API, written in Vapor. I would like to follow the JSON API Spec.

I am struggling with understanding how I can create my response object in the correct format.

For example, I would like my responses to be structured as so...

{
  "links": {
    "self": "http://example.com/dish",
    "next": "http://example.com/dish?page=2",
    "last": "http://example.com/dish?page=10"
  },
  "data": [{
    "title": "Spag Bol",
    "course": "main",
    "description": "BasGetti",
    "price": 3.9900000000000002
  },
  {
    "title": "Ice Cream",
    "course": "desert",
    "description": "Vanilla",
    "price": 0.98999999999999999
  }]
}

I can return the contents of data quite simply if POST to this endpoint (pseudo code)

router.post(Dish.self, at: "api/dish") { req, data -> Future<Dish> in
    return Future.map(on: req, { () -> Dish in
        data.id = 001
        return data
    })
}

I tried creating an ApiResponse class and passing in the data so I could structure the response but this did not work with the error Cannot convert return expression of type 'ApiResonse' to return type 'Dish'

   router.post(Dish.self, at: "api/dish") { req, data -> Future<Dish> in
        return Future.map(on: req, { () -> Dish in
            data.id = 001
            return ApiResonse(links: Links(self: "http://google.com", next: "http://google.com", last: "http://google.com"), data: data)
        })
    }

I am not sure how I can do this. These are the attempted classes

final class Dish: Content {
    var id: Int?
    var title: String
    var description: String
    var course: String
    var price: Double

    init(title: String, description: String, course: String, price: Double) {
        self.title = title
        self.description = description
        self.course = course
        self.price = price
    }
}

struct Links {
    var `self`: String?
    var next: String?
    var last: String?
}

class ApiResonse {
    var links: Links?
    var data: Any

    init(links: Links, data: Any) {
        self.links = links
        self.data = data
    }
}

Do I need to use Generics to set up the response class? Is anyone able to provide an example?


Solution

    1. Each class or struct in the compound object ApiResponse needs to comply with the Content protocol. The Content protocol includes the Codable protocol for JSON decoding and encoding.

    2. Note that Any does not comply with the Codable protocol, and therefore Any can not be used as any component part of a Response. See Vapor 3 Docs: "Using Content" and Vapor 4 Docs: "Content" for more detailed information.

      Vapor 3: all content types (JSON, protobuf, URLEncodedForm, Multipart, etc) are treated the same. All you need to parse and serialize content is a Codable class or struct.

      Vapor 4: Vapor's content API allows you to easily encode / decode Codable structs to / from HTTP messages.

    3. An object or compound object which fully complies with Content can be used as a ResponseEncodable response.

    4. The ApiResponse model can be generic when each route endpoint resolves to a specific Content protocol compliant type.

    An example project with the code below is on GitHub: VaporExamplesLab/Example-SO-VaporJsonResponse.

    Example Models

    struct Dish: Content {
        var id: Int?
        var title: String
        var description: String
        var course: String
        var price: Double
        
        init(id: Int? = nil, title: String, description: String, course: String, price: Double) {
            self.id = id
            self.title = title
            self.description = description
            self.course = course
            self.price = price
        }
    }
    
    struct Links: Content {
        var current: String?
        var next: String?
        var last: String?
    }
    
    struct ApiResponse: Content {
        var links: Links?
        var dishes: [Dish]
        
        init(links: Links, dishes: [Dish]) {
            self.links = links
            self.dishes = dishes
        }
    }
    

    Example POST: Returns ApiResponse

    router.post(Dish.self, at: "api/dish") { 
        (request: Request, dish: Dish) -> ApiResponse in
        var dishMutable = dish
        dishMutable.id = 001
        
        var links = Links()
        links.current = "http://example.com"
        links.next = "http://example.com"
        links.last = "http://example.com"
    
        return ApiResponse(links: links, dishes: [dishMutable])
    }
    

    Example POST: Returns Future<ApiResponse>

    router.post(Dish.self, at: "api/dish-future") { 
        (request: Request, dish: Dish) -> Future<ApiResponse> in
        var dishMutable = dish
        dishMutable.id = 002
        
        var links = Links()
        links.current = "http://example.com"
        links.next = "http://example.com"
        links.last = "http://example.com"
        
        return Future.map(on: request, { 
            () -> ApiResponse in
            return ApiResponse(links: links, dishes: [dishMutable])
        }) 
    }
    

    JSON Response Received

    The Response body for the above code produces the following:

    {
      "links": {
        "current": "http://example.com",
        "next": "http://example.com",
        "last": "http://example.com"
      },
      "dishes": [
        {
          "id": 1,
          "title": "Aztec Salad",
          "description": "Flavorful Southwestern ethos with sweet potatos and black beans.",
          "course": "salad",
          "price": 1.82
        }
      ]
    }
    

    Generic Model

    struct ApiResponseGeneric<T> : Content where T: Content { 
        var links: Links?
        var data: T
        
        init(links: Links, data: T) {
            self.links = links
            self.data = data
        }
    }
    

    Concrete Route Endpoint

    router.post(Dish.self, at: "api/dish-generic-future") { 
        (request: Request, dish: Dish) -> Future<ApiResponseGeneric<[Dish]>> in
        var dishMutable = dish
        dishMutable.id = 004
        
        var links = Links()
        links.current = "http://example.com"
        links.next = "http://example.com"
        links.last = "http://example.com"
        
        return Future.map(on: request, { 
            () -> ApiResponseGeneric<[Dish]> in
            return ApiResponseGeneric<[Dish]>(links: links, data: [dishMutable])
        }) 
    }