Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Functional Networking

Functional Networking

This talk presents a set of refactorings that will help make your Swift networking code more functional. It also features a tool, Bow OpenAPI, that generates your network layer from a Swagger/OpenAPI specification file into a Swift Package.

Tomás Ruiz-López

February 03, 2020
Tweet

More Decks by Tomás Ruiz-López

Other Decks in Programming

Transcript

  1. openapi: "3.0.0" info: title: Blogging platform version: "1.0.0" paths: /articles:

    get: tags: - Article operationId: getArticlesWithTag parameters: - name: tag in: query required: true schema: type: string responses: '200': description: The list of articles for the specified tag content: application/json: schema: $ref: '#/components/schemas/Articles'
  2. openapi: "3.0.0" info: title: Blogging platform version: "1.0.0" paths: /articles:

    get: tags: - Article operationId: getArticlesWithTag parameters: - name: tag in: query required: true schema: type: string responses: '200': description: The list of articles for the specified tag content: application/json: schema: $ref: '#/components/schemas/Articles'
  3. openapi: "3.0.0" info: title: Blogging platform version: "1.0.0" paths: /articles:

    get: tags: - Article operationId: getArticlesWithTag parameters: - name: tag in: query required: true schema: type: string responses: '200': description: The list of articles for the specified tag content: application/json: schema: $ref: '#/components/schemas/Articles'
  4. openapi: "3.0.0" info: title: Blogging platform version: "1.0.0" paths: /articles:

    get: tags: - Article operationId: getArticlesWithTag parameters: - name: tag in: query required: true schema: type: string responses: '200': description: The list of articles for the specified tag content: application/json: schema: $ref: '#/components/schemas/Articles'
  5. components: schemas: Articles: type: array items: $ref: '#/components/schemas/Article' Article: type:

    object required: - id - title properties: id: type: integer name: type: string
  6. components: schemas: Articles: type: array items: $ref: '#/components/schemas/Article' Article: type:

    object required: - id - title properties: id: type: integer name: type: string
  7. components: schemas: Articles: type: array items: $ref: '#/components/schemas/Article' Article: type:

    object required: - id - title properties: id: type: integer name: type: string
  8. func getArticles(withTag tag: String, callback: @escaping (Result<[Article], Error>) !-> Void)

    { let path = basePath + "/articles" var components = URLComponents(string: path) components!?.queryItems = [URLQueryItem(name: "tag", value: tag)] guard let url = components!?.url else { callback(.failure(NetworkError.malformedURL)) return } var request = URLRequest(url: url) request.httpMethod = "GET" headers.forEach { header in request.setValue(header.value, forHTTPHeaderField: header.key) } send(request: request, callback: callback) }
  9. func getArticles(withTag tag: String, callback: @escaping (Result<[Article], Error>) !-> Void)

    { let path = basePath + "/articles" var components = URLComponents(string: path) components!?.queryItems = [URLQueryItem(name: "tag", value: tag)] guard let url = components!?.url else { callback(.failure(NetworkError.malformedURL)) return } var request = URLRequest(url: url) request.httpMethod = "GET" headers.forEach { header in request.setValue(header.value, forHTTPHeaderField: header.key) } send(request: request, callback: callback) }
  10. func getArticles(withTag tag: String, callback: @escaping (Result<[Article], Error>) !-> Void)

    { let path = basePath + "/articles" var components = URLComponents(string: path) components!?.queryItems = [URLQueryItem(name: "tag", value: tag)] guard let url = components!?.url else { callback(.failure(NetworkError.malformedURL)) return } var request = URLRequest(url: url) request.httpMethod = "GET" headers.forEach { header in request.setValue(header.value, forHTTPHeaderField: header.key) } send(request: request, callback: callback) }
  11. func getArticles(withTag tag: String, callback: @escaping (Result<[Article], Error>) !-> Void)

    { let path = basePath + "/articles" var components = URLComponents(string: path) components!?.queryItems = [URLQueryItem(name: "tag", value: tag)] guard let url = components!?.url else { callback(.failure(NetworkError.malformedURL)) return } var request = URLRequest(url: url) request.httpMethod = "GET" headers.forEach { header in request.setValue(header.value, forHTTPHeaderField: header.key) } send(request: request, callback: callback) }
  12. func send(request: URLRequest, callback: @escaping (Result<[Article], Error>) !-> Void) {

    URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode([Article].self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() }
  13. func send(request: URLRequest, callback: @escaping (Result<[Article], Error>) !-> Void) {

    URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode([Article].self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() }
  14. func send(request: URLRequest, callback: @escaping (Result<[Article], Error>) !-> Void) {

    URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode([Article].self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() }
  15. func send(request: URLRequest, callback: @escaping (Result<[Article], Error>) !-> Void) {

    URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode([Article].self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() }
  16. func send(request: URLRequest, callback: @escaping (Result<[Article], Error>) !-> Void) {

    URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode([Article].self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() }
  17. func send(request: URLRequest, callback: @escaping (Result<[Article], Error>) !-> Void) {

    URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode([Article].self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() }
  18. func send(request: URLRequest, callback: @escaping (Result<[Article], Error>) !-> Void) {

    URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode([Article].self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() } Insufficient abstraction
  19. func send<T: Decodable>(request: URLRequest, callback: @escaping (Result<T, Error>) !-> Void)

    { URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let response = response as? HTTPURLResponse { switch response.statusCode { case 200 !!..< 300: if let decoded = try? JSONDecoder().decode(T.self, from: data) { callback(.success(decoded)) } else { callback(.failure(NetworkError.notDecodable)) } default: callback(.failure(NetworkError.malformedURL)) } } else if let error = error { callback(.failure(error)) } }.resume() } Insufficient abstraction Parametric polymorphism
  20. extension URLSession { func dataTaskIO(with request: URLRequest) !-> IO<Error, (Data,

    URLResponse)> { IO.async { callback in self.dataTask(with: request) { data, response, error in if let data = data, let response = response { callback(.right((data, response))) } else if let error = error { callback(.left(error)) } }.resume() }^ } }
  21. func send<T: Decodable>(request: URLRequest) !-> IO<Error, T> { URLSession.shared.dataTaskIO(with: request)

    .flatMap { response, data in guard let response = response as? HTTPURLResponse else { return IO.raiseError(NetworkError.malformedURL) } switch response.statusCode { case 200 !!..< 300: return JSONDecoder().safeDecode(T.self, from: data) default: return IO.raiseError(NetworkError.malformedURL) } }^ } Global dependencies
  22. func send<T: Decodable>(request: URLRequest) !-> EnvIO<APIConfig, Error, T> { EnvIO

    { config in config.session.dataTaskIO(with: request) .flatMap { response, data in guard let response = response as? HTTPURLResponse else { return IO.raiseError(NetworkError.malformedURL) } switch response.statusCode { case 200 !!..< 300: return config.decoder.safeDecode(T.self, from: data) default: return IO.raiseError(NetworkError.malformedURL) } }^ } } Global dependencies Environmental Effects
  23. let config = API.Config(basePath: “https:!//url-to-my-server.com") let articles = try? articlesRequest.provide(config).unsafeRunSync()

    articlesRequest.provide(config).unsafeRunAsync { either in either.fold({ httpError in !/* !!... !*/ }, { articles in !/* !!... !*/ }) } Running a request
  24. import MyAPITest func testEmptyResponse() { let successConfig = API.Config(basePath: "https:!//url-to-my-server.com")

    .stub(json: "[]", code: 200) let expected = [] assert(API.article.getArticles(), withConfig: successConfig, succeeds: expected, "An empty array was expected") } func testContentResponse() { let successConfig = API.Config(basePath: "https:!//url-to-my-server.com") .stub(contentsOfFile: fileURL, code: 200) let expected = [ Article(identifier: 1234, title: "Intro to Bow OpenAPI") ] assert(API.article.getArticles(), withConfig: successConfig, succeeds: expected, "An array with a single element was expected") } Testing a request
  25. import MyAPITest func testEmptyResponse() { let successConfig = API.Config(basePath: "https:!//url-to-my-server.com")

    .stub(json: "[]", code: 200) let expected = [] assert(API.article.getArticles(), withConfig: successConfig, succeeds: expected, "An empty array was expected") } func testContentResponse() { let successConfig = API.Config(basePath: "https:!//url-to-my-server.com") .stub(contentsOfFile: fileURL, code: 200) let expected = [ Article(identifier: 1234, title: "Intro to Bow OpenAPI") ] assert(API.article.getArticles(), withConfig: successConfig, succeeds: expected, "An array with a single element was expected") } Testing a request
  26. import MyAPITest func testEmptyResponse() { let successConfig = API.Config(basePath: "https:!//url-to-my-server.com")

    .stub(json: "[]", code: 200) let expected = [] assert(API.article.getArticles(), withConfig: successConfig, succeeds: expected, "An empty array was expected") } func testContentResponse() { let successConfig = API.Config(basePath: "https:!//url-to-my-server.com") .stub(contentsOfFile: fileURL, code: 200) let expected = [ Article(identifier: 1234, title: "Intro to Bow OpenAPI") ] assert(API.article.getArticles(), withConfig: successConfig, succeeds: expected, "An array with a single element was expected") } Testing a request
  27. Bow vs. Combine Works in earlier versions of iOS and

    macOS Lazyness guarantees referential transparency
  28. binding( user !<- API.user.getMyProfile(), articles !<- API.article.getArticles(withTag: "featured"), home !<-

    makeHome(for: user.get, with: articles.get), yield: home.get) Bow vs. Combine Convenient imperative-style syntax