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

Functional Networking - The Director's Cut

Functional Networking - The Director's Cut

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

March 04, 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. func dataTask(with request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) !->

    Void) Illegal states Data URLResponse Error nil nil nil nil nil error nil response nil nil response error value nil nil value nil error value response nil value response error
  21. 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() }^ } }
  22. 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
  23. func send<T: Decodable>(request: URLRequest) !-> IO<Error, T> func send<T: Decodable>(request:

    URLRequest, session: URLSession, decoder: SafeDecoder) !-> IO<Error, T> func send<T: Decodable>(request: URLRequest, config: APIConfig) !-> IO<Error, T> func send<T: Decodable>(request: URLRequest) !-> (APIConfig) !-> IO<Error, T> func send<T: Decodable>(request: URLRequest) !-> EnvIO<APIConfig, Error, T> Global dependencies Environmental Effects
  24. 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
  25. 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
  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. 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
  28. 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
  29. Bow vs. Combine Works in earlier versions of iOS and

    macOS Lazyness guarantees referential transparency
  30. 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
  31. binding( user !<- API.user.getMyProfile(), (featured, news) !<- parallel(API.article.getArticles(withTag: "featured"), API.article.getArticles(withTag:

    "news")), home !<- makeHome(for: user.get, with: featured.get + news.get), yield: home.get) Bow vs. Combine Convenient imperative-style syntax