A server almost always returns JSON objects nested in other objects.
Consider a pair of requests and two cases where a server can return nested responses.
Requests:
GET books/
Returns a list of books as an array ofBook
.GET books/{book_id}
Returns aBook
byid
.
public struct Book: Codable, Identifiable {
public let id: Int
public let name: String
}
In the first case, a server will wrap the response objects in data
.
{
"data": "content"
}
GET books/
The request to receive all the books will return an array wrapped in data
.
{
"data": [
{
"id": 1,
"name": "Mu mu",
}
]
}
GET books/{book_id}
The request to receive a book by id
will return one book wrapped in data
{
"data": {
"id": "A-1",
"name": "Mu mu",
}
}
To hide the data
wrapper, let's create JsonEndpoint
, which will get us the necessary Content
.
protocol JsonEndpoint: Endpoint where Content: Decodable {}
extension JsonEndpoint {
public func content(from response: URLResponse?, with body: Data) throws -> Content {
let decoder = JSONDecoder()
let value = try decoder.decode(ResponseData<Content>.self, from: body)
return value.data
}
}
private struct ResponseData<Content>: Decodable where Content: Decodable {
let data: Content
}
As a result, our requests hide the nesting of the response.
BookListEndpoint.Content = [Book]
BookEndpoint.Content = Book
public struct BookEndpoint: JsonEndpoint {
public typealias Content = Book
// ..,
}
public struct BookListEndpoint: JsonEndpoint {
public typealias Content = [Book]
// ..,
}
In the second more complex case, the server will send responses nested with different keys.
GET books/
The request to receive all books will return an array wrapped in book_list
.
{
"book_list": [
{
"id": 1,
"name": "Mu mu",
}
]
}
GET books/{book_id}
The request to receive a book by id will return a book wrapped in book
.
{
"book": {
"id": "A-1",
"name": "Mu mu",
}
}
To unwrap responses, create JsonEndpoint
with the content(from:)
method which will unwrap the responses.
protocol JsonEndpoint: Endpoint where Content: Decodable {
associatedtype Root: Decodable = Content
func content(from root: Root) -> Content
}
extension JsonEndpoint {
public func content(from response: URLResponse?, with body: Data) throws -> Content {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let root = try decoder.decode(Root.self, from: body)
return content(from: root)
}
}
Thus, the request to receive all the books will look like this.
struct BookListResponse: Decodable {
let bookList: [Book]
}
public struct BookListEndpoint: JsonEndpoint {
public typealias Content = [Book]
func content(from root: BookListResponse) -> Content {
return root.bookList
}
public func makeRequest() throws -> URLRequest {
return URLRequest(url: URL(string: "books")!)
}
}
Notice that
BookListResponse
andcontent(from:)
remainsinternal
and hide the features of the response format.
The request to get a book by id
will look like this.
struct BookResponse: Decodable {
let book: Book
}
public struct BookEndpoint: JsonEndpoint {
public typealias Content = Book
public let id: Book.ID
public init(id: Book.ID) {
self.id = id
}
func content(from root: BookResponse) -> Content {
return root.book
}
public func makeRequest() throws -> URLRequest {
let url = URL(string: "books")!.appendingPathComponent(id)
return URLRequest(url: url)
}
}
In the end, I would note that these two cases can be combined, and it will allow you to work without a boilerplate with complex APIs.