https://a.storyblok.com/f/270183/37194/b5f577e992/voice_swift-vapor_p1_1200x600.png

Criação de um aplicativo de áudio “drop-in” com SwiftUI e Vapor – Parte 1

Publicado em March 2, 2021

Tempo de leitura: 10 minutos

Observação: algumas das ferramentas ou métodos descritos neste artigo podem não ter mais suporte ou estar desatualizados. Para obter conteúdo atualizado ou suporte, consulte nossas postagens mais recentes ou nossa Documentação

Introdução

Os aplicativos de áudio do tipo “drop-in” estão se tornando muito populares, com o Clubhouse, o Soapbox, o Twitter Spaces e outros ganhando bastante força. Neste tutorial, você utilizará a Conversation API com o Client SDK para criar seu próprio aplicativo de áudio do tipo “drop-in”. O tutorial é dividido em duas partes: esta primeira parte abordará o servidor de back-end, e a segunda parte abordará o aplicativo para iOS.

Pré-requisitos

  • Xcode 12 e Swift 5 ou versão posterior

  • Vapor 4.0 instalado no seu computador

  • ngrok para expor seu computador local à internet

  • Nossa interface de linha de comando, que você pode instalar com npm install @vonage/cli -g.

Criação de uma aplicação da Vonage

Para criar o aplicativo, você utilizará a interface de linha de comando da Vonage. Caso ainda não tenha configurado a CLI, execute vonage config:set --apiKey=API_KEY --apiSecret=API_SECRET no seu terminal, substituindo “API Key” e “Secret” pelos valores encontrados na página de configurações da sua conta.

Primeiro, crie um diretório usando mkdir vonageapi, depois acesse o diretório com cd vonageapi. Em seguida, crie o aplicativo Vonage com vonage apps:create VaporConvAPI --rtc_event_url=https://example.com/. Esse comando salvará a chave privada do seu aplicativo no vaporconvapi.key arquivo e exibirá o ID do seu aplicativo. Você precisará desses dois valores para as etapas seguintes.

Criar um projeto de vapor

Crie um projeto Vapor usando o comando “novo projeto” vapor new VaporConvAPI no seu terminal. O terminal exibirá algumas mensagens, perguntando primeiro se você deseja usar o Fluent. Responda que sim e escolha o SQLite como banco de dados. Em seguida, será perguntado se você deseja usar o Leaf; responda que não.

Fluent é uma estrutura de mapeamento objeto-relacional que usaremos para armazenar informações do usuário no banco de dados. Assim que o comando for concluído, mude para a pasta do projeto usando cd VaporConvAPI.

Vapor project setup terminal output

Em seguida, copie seu vaporconvapi.key arquivo do diretório raiz do seu projeto para a pasta do projeto Vapor Sources/App/ . Feito isso, você pode abrir o projeto no Xcode usando vapor xcode. Quando o Xcode abrir, ele começará a baixar as dependências das quais o Vapor depende, usando o Swift Package Manager (SPM). Para visualizar as dependências, você pode abrir o Package.swift arquivo.

Por padrão, o Xcode executa seu aplicativo a partir de um diretório local aleatório. Como você vai carregar o vaporconvapi.key arquivo, é necessário definir um diretório de trabalho personalizado. Vá para Produto > Esquema > Editar Esquema... e defina o diretório de trabalho como a pasta raiz do seu projeto.

Setting custom working directory

Autenticação de usuário

Ao utilizar seu aplicativo, você precisará autenticar os usuários para usar o Client SDK no aplicativo para iOS. A Conversation API possui o conceito de Usuários, um objeto que identifica um usuário exclusivo da Vonage no contexto do seu aplicativo Vonage. Seu servidor de back-end também manterá o controle dos usuários, que terão um mapeamento um-para-um com o usuário da Vonage. Para diferenciar os dois, os usuários no seu servidor de back-end serão referidos daqui em diante como “usuários do banco de dados”. Assim que você tiver um usuário registrado e salvo, o servidor usará essas informações para gerar um JSON Web Token (JWT) para que o Client SDK faça o login.

Criar o modelo do usuário do banco de dados

Na Models pasta, exclua o Todo.swift arquivo e crie um novo arquivo chamado User.swift , acessando Arquivo > Novo > Arquivo (CMD + N). Em seguida, crie uma nova classe chamada User , que será o Modelo Fluent para os usuários do banco de dados:

import Fluent

final class User: Model {
    
    static let schema = "users"
    
    @ID(custom: "id", generatedBy: .user) var id: String?
    @Field(key: "name") var name: String
    
    init() {}
    
    init(id: String?, name: String) {
        self.id = id
        self.name = name
    }
}

A schema propriedade será o nome da tabela no banco de dados; a id e name serão os campos da tabela. A id é opcional e gerada pelo usuário, pois o ID de usuário da Vonage será usado aqui, mas ainda não está disponível.

Criar a migração do usuário do banco de dados

Para criar a tabela no banco de dados, você precisará de uma migração. As migrações definem alterações no banco de dados; neste caso, a criação da tabela User. Na Migrations pasta, exclua o CreateTodo.swift arquivo e crie um novo arquivo chamado CreateUser.swift. Em seguida, crie uma nova estrutura chamada CreateUser:

import Fluent

struct CreateUser: Migration {
    
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        database.schema(User.schema)
            .field("id", .string, .identifier(auto: false))
            .field("name", .string, .required)
            .create()
    }
    
    func revert(on database: Database) -> EventLoopFuture<Void> {
        database.schema(User.schema).delete()
    }
}

Ambas as funções, prepare e revert, são exigidas pelo Mirgration protocolo. prepare é chamada quando a migração é executada; observe como o esquema e os campos correspondem à User classe que você acabou de criar. A id propriedade é definida como um identificador que não é autoincrementado, já que o ID de usuário da Vonage será usado, conforme mencionado anteriormente.

Agora você pode adicionar as migrações ao seu projeto, abrir o configure.swift arquivo, exclua a app.migrations.add(CreateTodo()) linha e adicione:

app.migrations.add(CreateUser())
try app.autoMigrate().wait()

Isso executará a CreateUser execução automática da migração para você quando o servidor for iniciado e somente quando necessário.

Gerar o JWT

Tanto a Conversation API quanto os SDKs do Vonage Client utilizam JWTs para autenticação. Os JWTs são um método para representar reivindicações de forma segura entre duas partes. Você pode ler mais sobre JWTs em JWT.io ou sobre as reivindicações compatíveis com a Conversation API na documentação da Conversation API. Abra o Package.swift arquivo e adicione uma dependência para Swift-JWT no dependencies , bem como na dependencies matriz do destino:

...
dependencies: [
    // 💧 A server-side Swift web framework.
    .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
    .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
    .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"),
    .package(name: "SwiftJWT", url: "https://github.com/Kitura/Swift-JWT.git", from: "3.0.0")
],
targets: [
    .target(
        name: "App",
        dependencies: [
            .product(name: "Fluent", package: "fluent"),
            .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
            .product(name: "Vapor", package: "vapor"),
            .product(name: "SwiftJWT", package: "SwiftJWT")
        ],
        swiftSettings: [
            // Enable better optimizations when building in Release configuration. Despite the use of
            // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release
            // builds. See <https://github.com/swift-server/guides#building-for-production> for details.
            .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
        ]
    ),
...

Ao salvar o arquivo, o SPM fará o download SwiftJWT. Para usá-lo, crie um novo arquivo na Models pasta chamada Auth.swift:

import Vapor
import SwiftJWT

struct Auth {
    private let applicationId: String
    
    lazy var adminJWT: String = {
        return makeJwt()
    }()
    
    private let jwtSigner: JWTSigner = {
        let privateKeyPath = URL(fileURLWithPath: "Sources/App/vaporconvapi.key")
        let privateKey: Data = try! Data(contentsOf: privateKeyPath, options: .alwaysMapped)
        return JWTSigner.rs256(privateKey: privateKey)
    }()
    
    init(applicationId: String) {
        self.applicationId = applicationId
    }
    
    func makeJwt(sub: String? = nil, acl: JwtClaim.Paths? = nil) -> String {
        let iat = Date().timeIntervalSince1970.rounded()
        let exp = iat.advanced(by: 21600.0)
        let claims = JwtClaim(applicationId: applicationId, iat: iat, jti: UUID(), exp: exp, sub: sub, acl: acl)
        var jwt = JWT(claims: claims)
        return try! jwt.sign(using: jwtSigner)
    }
}

A jwtSigner propriedade usa a chave privada do seu aplicativo Vonage para assinar seu JWT. Ela é usada na makeJwt função, que recebe um sujeito (sub) opcional e uma lista de controle de acesso (ACL). Os JWTs de administrador são criados sem fornecer um sub; no caso da Conversation API, uma reivindicação sub seria o nome de usuário de um usuário da Vonage. Para codificar as reivindicações corretamente, SwiftJWT fornece um Claim protocolo, e criamos uma nova estrutura que está em conformidade com o Claim protocolo no mesmo arquivo:

struct JwtClaim: Claims {
    typealias Paths = [String: [String: [String: String]]]
    
    let applicationId: String
    let iat: TimeInterval
    let jti: UUID
    let exp: TimeInterval
    let sub: String?
    let acl: Paths?
    
    enum CodingKeys: String, CodingKey {
        case iat, jti, exp, sub, acl
        case applicationId = "application_id"
    }
    
    static let defaultPaths: Paths = ["paths":
                                        [
                                            #"/*/users/**"#: [:],
                                            #"/*/conversations/**"#: [:],
                                            #"/*/sessions/**"#: [:],
                                            #"/*/devices/**"#: [:],
                                            #"/*/image/**"#: [:],
                                            #"/*/media/**"#: [:],
                                            #"/*/push/**"#: [:],
                                            #"/*/knocking/**"#: [:],
                                            #"/*/legs/**"#: [:]
                                        ]
                                     ]
}

As propriedades na JwtClaim estrutura correspondem às reivindicações esperadas pela Conversation API. Em um ambiente de produção, você definiria um prazo de validade curto para o JWT e forneceria apenas os caminhos da ACL necessários.

Agora crie uma instância da Auth estrutura no routes.swift arquivo usando seu ID de aplicativo da Vonage:

import Fluent
import Vapor

func routes(_ app: Application) throws {
    var auth = Auth(applicationId: "APP_ID")
}

Criação de um usuário da Vonage

Em seguida, você pode começar a criar os endpoints para o aplicativo iOS. O primeiro endpoint será para autenticação. O servidor verificará primeiro se existe um usuário no banco de dados que corresponda ao nome de usuário recebido. Se houver, o servidor retornará um JWT. Se o usuário não existir no banco de dados, ele fará uma chamada à Conversation API para criar um usuário da Vonage, salvará os detalhes no banco de dados e, em seguida, retornará um JWT.

Diagram of the authentication flow

Primeiro, crie um novo arquivo chamado APIModels.swift no Models diretório. Esse arquivo será onde você criará todas as estruturas necessárias para os endpoints. A primeira estrutura que você precisa criar é a AuthBody struct:

import Vapor

struct AuthBody: Content {
    let name: String
}

É isso que o cliente iOS enviará ao servidor. A estrutura está em conformidade com o Content protocolo do Vapor. Uma vantagem significativa de usar o Vapor é que você pode contar com a segurança de tipos da linguagem Swift. Você pode modelar entradas e saídas para o seu servidor usando estruturas que seguem o Codable protocolo, como Content que está em conformidade com Codable.

As estruturas a seguir modelam a entrada esperada da Conversation API, a resposta da Conversation API e a resposta que o servidor enviará ao aplicativo iOS:

struct IDResponse: Content {
    let id: String
}

struct UserAuth: Content {
    struct Body: Content {
        let name: String
        let displayName: String
        let imageURL: String
        
        init(name: String) {
            self.name = name
            self.displayName = name
            self.imageURL = "https://example.com/image.png"
        }
        
        enum CodingKeys: String, CodingKey {
            case name
            case displayName = "display_name"
            case imageURL = "image_url"
        }
    }
    
    struct Response: Content {
        let name: String
        let jwt: String
    }
}

Foram fornecidos valores padrão para imageURL e displayName para os fins deste tutorial. As APIs da Vonage esperam campos em snake case, portanto, as estruturas possuem a CodingKeys enum para mapear os nomes de suas propriedades para seus equivalentes em snake case.

Agora que os modelos já estão definidos, você pode adicionar a nova rota à routes função no routes.swift arquivo:

func routes(_ app: Application) throws {
    var auth = Auth(applicationId: "APP_ID")

    app.post("auth") { req -> EventLoopFuture<UserAuth.Response> in
        let authBody = try req.content.decode(AuthBody.self)
        
        return User.query(on: req.db)
            .filter(\.$name == authBody.name)
            .first()
            .flatMap { user -> EventLoopFuture<UserAuth.Response> in
                if let user = user {
                    let userAuthResponse = UserAuth.Response(
                        name: user.name,
                        jwt: auth.makeJwt(sub: user.name, acl: JwtClaim.defaultPaths))
                    return req.eventLoop.makeSucceededFuture(userAuthResponse)
                } else {
                    
                }
            }
    }
}

Essa função define uma nova rota na /auth trajeto do servidor, que retorna um future com um UserAuth.Response tipo — o tipo que o aplicativo iOS espera.

O corpo da solicitação enviada ao servidor é decodificado na authBody variável. O corpo é então usado para filtrar os usuários do banco de dados. Como você está procurando um usuário (e os nomes de usuário são únicos), .first() é aplicado à resposta da consulta ao banco de dados, que retorna o tipo EventLoopFuture<User?>. Isso, então, é transformado no tipo esperado de EventLoopFuture<UserAuth.Response> com o flatMap clausula de fechamento.

Diagram of the authentication flow, first part circled

A segunda parte do fluxo continua no flatMap closure. Se o parâmetro opcional do banco de dados for nil, faça uma chamada à /v0.1/users da Conversation API para criar um usuário:

...
app.post("auth") { req -> EventLoopFuture<UserAuth.Response> in
        ...
        .flatMap { user -> EventLoopFuture<UserAuth.Response> in
            if let user = user {
                ...
            } else {
                return req.client.post(URI(scheme: "https", host: "api.nexmo.com", path: "v0.1/users")) { req in
                    req.headers.add(name: .authorization, value: "Bearer \(auth.adminJWT)")
                    try req.content.encode(UserAuth.Body(name: authBody.name), as: .json)
                }.flatMap { response -> EventLoopFuture<UserAuth.Response> in
                    let responseBody = try! response.content.decode(IDResponse.self)
                    let user = User(id: responseBody.id, name: authBody.name)
                    let userAuthResponse = UserAuth.Response(
                        name: user.name,
                        jwt: auth.makeJwt(sub: user.name, acl: JwtClaim.defaultPaths))
                return user.save(on: req.db).map { userAuthResponse }
            }
        }
}
...

Ao enviar uma solicitação à Conversation API, um authorization cabeçalho é adicionado à solicitação, juntamente com uma UserAuth.Body estrutura codificada como o corpo da solicitação. A resposta, o ID de usuário da Vonage do usuário criado, é novamente transformada em uma flatMap closure para o tipo esperado de EventLoopFuture<UserAuth.Response>.

Desta vez, há uma etapa adicional: criar um usuário do banco de dados e salvá-lo. Em um ambiente de produção, você deve usar uma senha para proteger o acesso dos usuários ao seu sistema e pode ir um pouco além, retornando um token de autenticação para futuras solicitações ao seu servidor.

Diagram of the authentication flow, second part circled

Com todo o percurso concluído, agora você pode observar o fluxo de dados desde a entrada até o servidor e, por meio de uma série de transformações encadeadas, obter a saída desejada.

Conversas sobre listagens

Assim que o aplicativo para iOS for autenticado, ele exibirá uma lista de salas de áudio nas quais o usuário poderá entrar. As salas de áudio serão o equivalente à conceito de da Conversation API. Para obter uma lista das conversas disponíveis para seu aplicativo Vonage, você pode chamar /v0.2/conversations. Adicione os modelos necessários ao APIModels arquivo:

...
struct Conversation: Content {
    struct Response: Content {
        let embedded: Embedded
        
        enum CodingKeys: String, CodingKey {
            case embedded = "_embedded"
        }
        
        struct Embedded: Content {
            let data: Conversation.Response.Data
        }
        
        struct Data: Content {
            let conversations: [Conv]
        }
        
        struct Conv: Content {
            let id: String
            let displayName: String
            
            enum CodingKeys: String, CodingKey {
                case id
                case displayName = "display_name"
            }
        }
    }
}
...

Em seguida, crie uma nova rota na routes função:

...
app.get("rooms") { req -> EventLoopFuture<[Conversation.Response.Conv]> in
    return req.client.get(URI(scheme: "https", host: "api.nexmo.com", path: "v0.2/conversations")) { req in
        req.headers.add(name: .authorization, value: "Bearer \(auth.adminJWT)")
    }.map { response -> [Conversation.Response.Conv] in
        let responseBody = try! response.content.decode(Conversation.Response.self)
        return responseBody.embedded.data.conversations
    }
}
...

Assim como na chamada anterior feita à Conversation API, um authorization cabeçalho é adicionado à solicitação. A resposta é então transformada no tipo de retorno esperado pelo aplicativo.

Iniciando uma conversa

O aplicativo para iOS precisa criar novas conversas/salas. Para criar uma nova conversa no seu aplicativo Vonage, ligue para /v0.2/conversations. Adicione uma Body estrutura à Conversation estrutura no APIModels arquivo:

struct Conversation: Content {
    ...
    struct Body: Content {
        let name: String = UUID().uuidString
        let displayName: String
        let imageURL: String = "https://example.com/image.png"
        let properties: [String: Int] = ["ttl": 300]
        
        enum CodingKeys: String, CodingKey {
            case name, properties
            case displayName = "display_name"
            case imageURL = "image_url"
        }
    }
}

Os valores padrão foram fornecidos novamente para fins do tutorial. Os nomes das conversas na Conversation API precisam ser únicos; por isso, utiliza-se um UUID aleatório. Em seguida, crie uma nova rota na routes função:

...
app.post("rooms") { req -> EventLoopFuture<IDResponse> in
    let conversationBody = try req.content.decode(Conversation.Body.self)
    return req.client.post(URI(scheme: "https", host: "api.nexmo.com", path: "v0.1/conversations")) { req in
        req.headers.add(name: .authorization, value: "Bearer \(auth.adminJWT)")
        try req.content.encode(conversationBody, as: .json)
    }.map { response -> IDResponse in
        let responseBody = try! response.content.decode(IDResponse.self)
        return responseBody
    }
}
...

Teste o servidor

Agora que suas rotas estão definidas, você pode compilar e executar (CMD + R). Quando terminar, seu servidor estará em execução localmente na porta 8080. Para disponibilizá-lo na internet, você pode usar o ngrok.
No seu terminal, execute ngrok http 8080. O ngrok gerará uma URL pública que redireciona as chamadas para sua máquina local.

ngrok running on port 8080

A URL do ngrok é o que o aplicativo iOS usará para se comunicar com o servidor. Você pode testar os endpoints que criou usando uma ferramenta de API, como o Postman, o Rested ou Hoppscotch:

  • POST /auth:

Hoppscotch output of a call to /auth

  • POST /rooms:

Hoppscotch output of a post call to /rooms

  • OBTER /rooms:

Hoppscotch output of a get call to /rooms

E agora?

A segunda parte deste tutorial irá criar um aplicativo de áudio para iOS com o SwiftUI e o Client SDK, que utiliza o servidor que você acabou de criar.

Image of the completed iOS application

Você pode encontrar o projeto concluído no GitHub. Saiba mais sobre a Conversation API em developer.vonage.come sobre o Vapor em vapor.codes.

Compartilhar:

https://a.storyblok.com/f/270183/400x400/19c02db2d3/abdul-ajetunmobi.png
Abdul AjetunmobiEx-funcionário da Vonage

Abdul é um Developer Advocate da Vonage. Ele tem experiência profissional na área de produtos de consumo como engenheiro de iOS. Em seu tempo livre, ele gosta de andar de bicicleta, ouvir música e orientar quem está dando os primeiros passos na área de tecnologia