Codificable para solicitudes de API y cómo ordenar el código

Hola Habr!

Comenzando con Swift 4, tenemos acceso al nuevo protocolo Codificable, que facilita la codificación / decodificación de modelos. Mis proyectos tienen mucho código para las llamadas a la API, y durante el último año he trabajado mucho para optimizar esta enorme variedad de código en algo muy ligero, conciso y simple eliminando el código repetido y usando Codable incluso para solicitudes de varias partes y parámetros de consulta de URL. Resultó ser varias clases excelentes en mi opinión para enviar solicitudes y analizar respuestas del servidor. Además de una conveniente estructura de archivos, que son los controladores para cada grupo de solicitudes, que arraigé al usar Vapor 3 en el back-end. Hace unos días, asigné todos mis desarrollos a una biblioteca separada y la llamé CodyFire. Me gustaría hablar de ella en este artículo.

Descargo de responsabilidad


CodyFire se basa en Alamofire, pero es más que un contenedor en Alamofire, es un enfoque de todo el sistema para trabajar con la API REST para iOS. Es por eso que no me preocupa que Alamofire esté cortando la quinta versión en la que habrá soporte Codificable, porque No matará mi creación.

Inicialización


Comencemos un poco desde lejos, a saber, que a menudo tenemos tres servidores:

dev - para el desarrollo, lo que comenzamos desde Xcode
etapa : para probar antes del lanzamiento, generalmente en TestFlight o InHouse
prod - production, para la AppStore

Y, por supuesto, muchos desarrolladores de iOS son conscientes de la existencia de Variables de entorno y los esquemas de inicio en Xcode, pero a lo largo de mi práctica (más de 8 años), el 90% de los desarrolladores escriben manualmente el servidor correcto de forma constante durante las pruebas o antes del ensamblaje, y esto es lo que me gustaría arreglar mostrando un buen ejemplo de cómo hacerlo bien.

Por defecto, CodyFire determina automáticamente en qué entorno se está ejecutando actualmente la aplicación, lo hace muy simple:

#if DEBUG //DEV environment #else if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" { //TESTFLIGHT environment } else { //APPSTORE environment } #endif 

Por supuesto, esto está bajo el capó, y en el proyecto en AppDelegate solo necesita registrar tres URL

 import CodyFire @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { let dev = CodyFireEnvironment(baseURL: "http://localhost:8080") let testFlight = CodyFireEnvironment(baseURL: "https://stage.myapi.com") let appStore = CodyFireEnvironment(baseURL: "https://api.myapi.com") CodyFire.shared.configureEnvironments(dev: dev, testFlight: testFlight, appStore: appStore) return true } } 

Y uno podría estar contento de esto y no hacer nada más.

Pero en la vida real, a menudo necesitamos probar servidores de desarrollo, etapa y producción en Xcode, y para esto le insto a que use esquemas de inicio.

imagen
Consejo: en la sección Administrar esquemas , no olvide marcar la casilla de verificación `compartida` para cada esquema para que estén disponibles para todos los desarrolladores del proyecto.

En cada esquema, debe escribir la variable de entorno `env` que puede tomar tres valores: dev, testFlight, appStore.

imagen

Y para que estos esquemas funcionen con CodyFire, debe agregar el siguiente código a AppDelegate.didFinishLaunchingWithOptions después de inicializar CodyFire

 CodyFire.shared.setupEnvByProjectScheme() 

Además, a menudo el jefe o los evaluadores de su proyecto pueden pedirle que cambie el servidor sobre la marcha en algún lugar de LoginScreen . Con CodyFire, puede implementar esto fácilmente cambiando el servidor en una línea, cambiando el entorno:

 CodyFire.shared.environmentMode = .appStore 

Esto funcionará hasta que se reinicie la aplicación, y si desea que se guarde después del inicio, guarde el valor en UserDefaults , verifique cuando la aplicación se inicie en AppDelegate y cambie el entorno al necesario.
Le dije a este importante punto, espero que haya más proyectos en los que el cambio del entorno se haga de manera hermosa. Y al mismo tiempo, ya hemos inicializado la biblioteca.

Estructura de archivos y controladores


Ahora puede hablar sobre mi visión de la estructura de archivos para todas las llamadas API, esto se puede llamar la ideología de CodyFire.

Veamos cómo se ve finalmente en el proyecto.

imagen

Ahora veamos los listados de archivos, comencemos con API.swift .

 class API { typealias auth = AuthController typealias post = PostController } 

Los enlaces a todos los controladores se enumeran aquí para que puedan llamarse fácilmente a través de `API.controller.method`.

 class AuthController {} 

API + Login.swift

 extension AuthController { struct LoginResponse: Codable { var token: String } static func login(email: String, password: String) -> APIRequest<LoginResponse> { return APIRequest("login").method(.post) .basicAuth(email: email, password: password) .addCustomError(.notFound, "User not found") } } 

En este decorador, declaramos una función para llamar a nuestra API:

- especificar punto final
- Método HTTP POST
- use wrapper para autenticación básica
- declarar el texto deseado para una respuesta específica del servidor (esto es conveniente)
- e indique el modelo mediante el cual se decodificarán los datos

¿Qué queda oculto?

- no es necesario especificar la URL completa del servidor, ya está configurado globalmente
- no tenía que indicar que esperamos recibir 200 OK si todo está bien
200 OK es el código de estado predeterminado esperado por CodyFire para todas las solicitudes, en cuyo caso se decodifican los datos y se llama a una devolución de llamada, que todo está bien, aquí están sus datos.
Además, en algún lugar del código de su pantalla de inicio de sesión, simplemente puede llamar

 API.auth.login(email: "test@mail.com", password: "qwerty").onError { error in switch error.code { case .notFound: print(error.description) //: User not found default: print(error.description) } }.onSuccess { token in //TODO:  auth token    print("Received auth token: "+ token) } 

onError y onSuccess son solo una pequeña parte de las devoluciones de llamada que APIRequest puede devolver, hablaremos de ellas más adelante.

En el ejemplo de entrada, consideramos solo la opción cuando los datos devueltos se decodifican automáticamente, pero puede decir que usted mismo podría implementarlos, y tendrá razón. Por lo tanto, consideremos la posibilidad de enviar datos según el modelo utilizando el formulario de registro como ejemplo.

API + Signup.swift

 extension AuthController { struct SignupRequest: JSONPayload { let email, password: String let firstName, lastName, mobileNumber: String init(email: String, password: String, firstName: String, lastName: String, mobileNumber: String) { self.email = email self.password = password self.firstName = firstName self.lastName = lastName self.mobileNumber = mobileNumber } } struct SignupResponse: Codable { let token: String } static func signup(_ request: SignupRequest) -> APIRequest<SignupResponse> { return APIRequest("signup", payload: request).method(.post) .addError(.conflict, "Account already exists") } } 

A diferencia del inicio de sesión, durante el registro transmitimos una gran cantidad de datos.

En este ejemplo, tenemos un modelo SignupRequest que se ajusta al protocolo JSONPayload (por lo tanto, CodyFire entiende el tipo de carga útil) para que el cuerpo de nuestra solicitud tenga la forma de JSON. Si necesita x-www-form-urlencoded, use FormURLEncodedPayload .

Como resultado, obtienes una función simple que acepta el modelo de carga útil
 API.auth.signup(request) 

y que, si tiene éxito, le devolverá un modelo de respuesta específico.

Creo que es genial, ¿verdad?

Pero, ¿y si es multiparte?


Veamos un ejemplo cuando puedes crear una publicación .

Post + Create.swift

 extension PostController { struct CreateRequest: MultipartPayload { var text: String var tags: [String] var images: [Attachment] var video: Data init (text: String, tags: [String], images: [Attachment], video: Data) { self.text = text self.tags = tags self.images = images self.video = video } } struct Post: Codable { let text: String let tags: [String] let linksToImages: [String] let linkToVideo: String } static func create(_ request: CreateRequest) -> APIRequest<CreateRequest> { return APIRequest("post", payload: request).method(.post) } } 

Este código podrá enviar un formulario multiparte con una variedad de archivos de imagen y con un video.
Veamos cómo llamar a un despacho. Aquí está el momento más interesante sobre Adjunto .

 let videoData = FileManager.default.contents(atPath: "/path/to/video.mp4")! let imageAttachment = Attachment(data: UIImage(named: "cat")!.jpeg(.high)!, fileName: "cat.jpg", mimeType: .jpg) let payload = PostController.CreateRequest(text: "CodyFire is awesome", tags: ["codyfire", "awesome"], images: [imageAttachment], video: videoData) API.post.create(payload).onProgress { progress in print(" : \(progress)") }.onError { error in print(error.description) }.onSuccess { createdPost in print("  : \(createdPost)") } 

El archivo adjunto es un modelo en el que, además de Datos, también se transmiten el nombre del archivo y su MimeType.

Si alguna vez envió un formulario de varias partes de Swift usando Alamofire o una URLRequest desnuda , estoy seguro de que apreciará la simplicidad de CodyFire .

Ahora ejemplos más simples pero no menos interesantes de llamadas GET.

Post + Get.swift

 extension PostController { struct ListQuery: Codable { let offset, limit: Int init (offset: Int, limit: Int) { self.offset = offset self.limit = limit } } static func get(_ query: ListQuery? = nil) -> APIRequest<[Post]> { return APIRequest("post").query(query) } static func get(id: UUID) -> APIRequest<Post> { return APIRequest("post/" + id.uuidString) } } 

El ejemplo más simple es

 API.post.get(id:) 

que en onSuccess le devolverá el modelo Post .

Aquí hay un ejemplo más interesante.

 API.post.get(PostController.ListQuery(offset: 0, limit: 100)) 

que toma un modelo ListQuery como entrada,
qué APIRequest finalmente se convierte en una ruta URL del formulario

 post?limit=0&offset=100 

y devolverá la matriz [Post] a onSuccess .

Por supuesto, puede escribir la ruta URL a la antigua usanza, pero ahora sabe que puede codificar totalmente.

La solicitud de ejemplo final será BORRAR

Publicar + Eliminar.swift

 extension PostController { static func delete(id: UUID) -> APIRequest<Nothing> { return APIRequest("post/" + id.uuidString) .method(.delete) .desiredStatusCode(.noContent) } } 

Hay dos puntos interesantes.

- el tipo de retorno es APIRequest, especifica el tipo genérico Nothing , que es un modelo codificable vacío.
- indicamos explícitamente que esperamos recibir 204 SIN CONTENIDO, y CodyFire solo llamará a onSuccess en este caso.

Ya sabe cómo llamar a este punto final desde su ViewController.

Pero hay dos opciones, la primera con onSuccess y la segunda sin. Lo miraremos

 API.post.delete(id:).execute() 

Es decir, si no le importa si la solicitud funciona , simplemente puede llamar a .execute () y eso es todo; de lo contrario, comenzará después de la declaración onSuccess del controlador.

Funciones disponibles


Autorización de cada solicitud.


Para firmar cada API de solicitud con cualquier encabezado http, se utiliza un controlador global, que puede establecer en algún lugar de AppDelegate . Además, puede usar el modelo clásico [String: String] o Codable para elegir.

Ejemplo para el titular de la autorización.

1. Codificable (recomendado)
 CodyFire.shared.fillCodableHeaders = { struct Headers: Codable { //NOTE:  nil,     headers var Authorization: String? var anythingElse: String } return Headers(Authorization: nil, anythingElse: "hello") } 

2. Clásico [Cadena: Cadena]
 CodyFire.shared.fillHeaders = { guard let apiToken = LocalAuthStorage.savedToken else { return [:] } return ["Authorization": "Bearer \(apiToken)"] } 

Agregue selectivamente algunos encabezados http a la solicitud


Esto se puede hacer al crear APIRequest, por ejemplo:

 APIRequest("some/endpoint").headers(["someKey": "someValue"]) 

Manejo de solicitudes no autorizadas


Puede procesarlos globalmente, por ejemplo en AppDelegate

 CodyFire.shared.unauthorizedHandler = { //   WelcomeScreen } 

o localmente en cada solicitud

 API.post.create(request).onNotAuthorized { //   } 

Si la red no está disponible


 API.post.create(request). onNetworkUnavailable { //   ,  ,     } 
de lo contrario, en onError obtendrá un error ._notConnectedToInternet

Comenzar algo antes de que comience la solicitud


Puede establecer .onRequestStarted y comenzar a mostrar, por ejemplo, un cargador en él.
Este es un lugar conveniente, porque no se llama en caso de falta de Internet, y no tiene que presentarse en vano para mostrar un cargador, por ejemplo.

Cómo deshabilitar / habilitar la salida de registro globalmente


 CodyFire.shared.logLevel = .debug CodyFire.shared.logLevel = .error CodyFire.shared.logLevel = .info CodyFire.shared.logLevel = .off 

Cómo deshabilitar la salida del registro para una sola solicitud


 .avoidLogError() 

Procese los registros a su manera


 CodyFire.shared.logHandler = { level, text in print("  CodyFire: " + text) } 

Cómo configurar el código de respuesta http esperado del servidor


Como dije anteriormente, de manera predeterminada, CodyFire espera recibir 200 OK, y si lo hace, comienza a analizar datos y llama a Success .

Pero el código esperado se puede establecer en forma de una enumeración conveniente, por ejemplo, para 201 CREATED

 .desiredStatusCode(.created) 

o incluso puedes configurar el código esperado personalizado

 .desiredStatusCode(.custom(777)) 

Cancelar solicitud


 .cancel() 

y puede descubrir que la solicitud se canceló declarando un controlador .onCancellation

 .onCancellation { //   } 

de lo contrario, se generará onError

Establecer un tiempo de espera para una solicitud


 .responseTimeout(30) //   30  

un controlador también se puede colgar en un evento de tiempo de espera

 . onTimeout { //    } 

de lo contrario, se generará onError

Establecer un tiempo de espera adicional interactivo


Esta es mi característica favorita. Una vez, un cliente de EE. UU. Me preguntó por ella, porque no le gustó que el formulario de inicio de sesión funcionara demasiado rápido, en su opinión, no parecía natural, como si fuera una falsificación, no una autorización.

La idea es que quería que la verificación del correo electrónico / contraseña durara 2 segundos o más. Y si dura solo 0.5 segundos, entonces necesita lanzar otros 1.5 y solo luego llamar a Success . Y si toma exactamente 2 o 2.5 segundos, llame a onSuccess inmediatamente.

 .additionalTimeout(2) // 2     

Codificador / decodificador de fecha personalizado


CodyFire tiene su propia enumeración DateCodingStrategy , en la que hay tres valores

- segundosDesde1970
- milisegundos desde1970
- formateado (_ customDateFormatter: DateFormatter)

DateCodingStrategy se puede configurar de tres maneras y por separado para decodificar y codificar
- globalmente en AppDelegate

 CodyFire.shared.dateEncodingStrategy = .secondsSince1970 let customDateFormatter = DateFormatter() CodyFire.shared.dateDecodingStrategy = .formatted(customDateFormatter) 

- para una solicitud

 APIRequest("some/endpoint") .dateDecodingStrategy(.millisecondsSince1970) .dateEncodingStrategy(.secondsSince1970) 

- o incluso por separado para cada modelo, solo necesita que el modelo coincida con CustomDateEncodingStrategy y / o CustomDateDecodingStrategy .

 struct SomePayload: JSONPayload, CustomDateEncodingStrategy, CustomDateDecodingStrategy { var dateEncodingStrategy: DateCodingStrategy var dateDecodingStrategy: DateCodingStrategy } 

Cómo agregar al proyecto


La biblioteca está disponible en GitHub bajo la licencia MIT.

La instalación actualmente solo está disponible a través de CocoaPods
 pod 'CodyFire' 


Realmente espero que CodyFire sea útil para otros desarrolladores de iOS, simplificará el desarrollo para ellos y, en general, hará que el mundo sea un poco mejor y la gente sea más amable.

Eso es todo, gracias por tu tiempo.

UPD: Soporte ReactiveCocoa y RxSwift agregado
 pod 'ReactiveCodyFire' # ReactiveCocoa pod 'RxCodyFire' # RxSwift #      'CodyFire',     

APIRequest para ReactiveCoca tendrá .signalProducer , y para RxSwift .observable

UPD2: ahora puede ejecutar múltiples solicitudes
Si es importante para usted obtener el resultado de cada consulta, use .and ()
Máximo en este modo, puede ejecutar hasta 10 solicitudes, se ejecutarán estrictamente una tras otra.
 API.employee.all() .and(API.office.all()) .and(API.car.all()) .and(API.event.all()) .and(API.post.all()) .onError { error in print(error.description) }.onSuccess { employees, offices, cars, events, posts in //    !!! } 

onRequestStarted, onNetworkUnavailable, onCancellation, onNotAuthorized, onTimeout también están disponibles.
onProgress - todavía en desarrollo

Si no le importan los resultados de sus consultas, puede usar .flatten ()
 [API.employee.all(), API.office.all(), API.car.all()].flatten().onError { print(error.description) }.onSuccess { print("flatten finished!") } 
Para ejecutarlos al mismo tiempo, simplemente agregue .concurrent (por: 3) esto permitirá que tres solicitudes se ejecuten simultáneamente, se puede especificar cualquier número.
Para omitir errores de consulta fallidos, agregue .avoidCancelOnError ()
Para obtener progreso, agregue .onProgress

UPD3: ahora puede configurar un servidor separado para cada solicitud
Es necesario crear las direcciones de servidor necesarias en algún lugar, por ejemplo, así
 let server1 = ServerURL(base: "https://server1.com", path: "v1") let server2 = ServerURL(base: "https://server2.com", path: "v1") let server3 = ServerURL(base: "https://server3.com") 
Y ahora puede usarlos directamente en la inicialización de la solicitud antes de especificar el punto final
 APIRequest(server1, "endpoint", payload: payloadObject) APIRequest(server2, "endpoint", payload: payloadObject) APIRequest(server3, "endpoint", payload: payloadObject) 
o puede especificar el servidor después de inicializar la solicitud
 APIRequest("endpoint", payload: payloadObject).serverURL(server1) 

Source: https://habr.com/ru/post/es427937/


All Articles