Workshop DoWhile Rocketseat: Programação funcional

Rodrigo Botti
ReZÉnha
Published in
17 min readDec 16, 2020

--

Currying e composição de funções na prática

Pequena tijela de cerâmica cheia de curry em pó com uma colher em cima
tumeric-spice-curry-seasoning créditos a stevepb

Introdução

Nos dias 14 e 15 de dezembro de 2020 tive a oportunidade de apresentar o workshop Programação funcional: Currying e composição de funções na prática no evento DoWhile organizado pela empresa Rocketseat.

O objetivo desse artigo é ser uma versão do workshop em forma de texto.

Um pouco sobre mim

Olá pessoal, sou Rodrigo Botti, sou engenheiro backend no Zé Delivery. Sou entusiasmado por programação funcional, tenho estudado e praticado bastante sobre o assunto. Além disso há tempos venho empregando esse paradigma profissionalmente.

O workshop

Nesse workshop vamos aprender sobre higher order functions, curry, aplicação parcial e como essas técnicas de programação funcional facilitam composição de funções.
No final vamos escrever uma pequena aplicação ilustrando como utilizando essas técnicas conseguimos deixar nosso código declarativo, expressivo, legível e extensível.

Observação: O repositório git base utilizado para condução do workshop está aqui. Recomendo muito a leitura do README — apesar de que repetirei muito do que está escrito lá aqui.

Os conceitos serão apresentados utilizando JavaScript (Node.JS) como linguagem, dessa forma, conhecimento sobre a sintaxe e funcionalidades básicas da linguagem são necessários.

Etapas

O workshop será dividido nas seguintes etapas:

  1. Higher order functions
  2. Curry
  3. Aplicação parcial
  4. Composição facilitada
  5. Aplicação: crawler concorrente de API com rate limit

Acompanhamento e pré-requisitos

Para não me prolongar muito, vou deixar o link das instruções que estão no README do repositório base. Recomendo sua leitura caso deseje acompanhar simultaneamente.

Sem mais delongas, vamos ao conteúdo.

1 . Higher Order Functions

Antes de começarmos a falar sobre os outros assuntos, precisamos primeiro entender o que são higher order functions.

Uma higher order function é uma função que recebe uma função como argumento ou retorna uma função.

O conceito é bem simples, mas vamos ver alguns exemplos de higher order functions diretamente do tipo Array da linguagem. Instâncias de Array têm os métodos map, filter e reduce. Cada um é uma higher order function: recebe outra função como argumento.

const numberArray = [1, 2, 3]const double = val => val * 2const doubled = numberArray.map(double)
// > [2, 4, 6]
const isOdd = val => val % 2 !== 0const odds = numberArray.filter(isOdd)
// > [1, 3]
const add = (x, y) => x + yconst sum = numberArray.reduce(add, 0)
// > 6

Como se pode ver, as funções recebem outra função como parâmetro. Mas por que? Por que não simplesmente executar o loop sobre os arrays? Transformar elementos (map), filtrar/remover elementos (filter) e acumular elementos (reduce) são operações comuns de se realizar num array. Essas higher order functions abstraem a iteração permitindo focar somente na parte que importa: executar lógica em cada elemento (transformação, avaliação de predicado e acumulação respectivamente).

Como assim? Imagine agora que se quer somente os números pares de um array. Tudo que precisa ser feito é escrever essa função de predicado e utilizar o filter. Num for loop imperativo teríamos que reescrever o loop e trocar o if interno.

Ou seja, podemos utilizar higher order functions para abstrair mecânicas e receber como parâmetro a computação que importa.

Isso também vale para o outro tipo de higher order functions: as que retornam outras funções.
Suponha que queremos um jeito de logar os parâmetros e os retornos das funções do nosso projeto. Um jeito ingênuo — e trabalhoso — de fazer isso, seria modificar o corpo de cada função para fazer isso. Não precisamos fazer isso, podemos utilizar higher order functions para abstrair esse comportamento!

// recebe uma função fn como parâmetro
const withLog = fn =>
(...args) => { // <----------------- retorna outra função
console.log('arguments: ', args)
const result = fn(...args) // <--- invocação de fn
console.log('result: ', result)
return result // <---------------- retorna o retorno de fn
}
// função de negócio com funcionalidade de log
const myBusinessFunction = withLog((param1, param2 /* ... */) => {
// lógica de negócio
})
// exemplo:
const loggedMax = withLog(Math.max)
loggedMax(666, -1000, 2020)
// > arguments: [ 666, -1000, 2020 ]
// > result: 2020
// > 2020

Observação: Essas funções que adicionam comportamento em cima de outra função — invocam a função original colocando comportamentos em volta — são geralmente chamadas de decorators.

2. Curry

Uma função é "curried" quando recebe múltiplos argumentos um de cada vez.

É o que? Bom vamos a exemplos. Suponha que temos uma função que adiciona dois números.

const add = (x, y) => x + y

Dizemos que essa função é curried quando está na forma

const add = x => y => x + y

Note que é uma função que (recebe um único parâmetro) retorna outra função que (recebe um único parâmetro) finalmente retorna o resultado de adicionar os dois parâmetros, ou seja, add é uma higher order function.

Por que fazer isso? Para conseguir algo chamado aplicação parcial.

Considerando a função curried de cima. Poderíamos fazer algo assim:

const increment = add(1)

Logo, a função increment é a função add mas com o parâmetro x = 1! Dessa forma podemos fazer:

increment(2) // 3 --> x=1, y=2
increment(5) // 6 --> x=5, y=5

3. Aplicação parcial

Agora imagine isso sendo extendido para funções de múltiplos argumentos onde qualquer um deles pode ser parcialmente aplicado como no exemplo anterior. Isso é aplicação parcial.

Aplicação parcial é uma função que teve alguns de seus argumentos mas não todos aplicados. Em outras palavras, é uma função que tem alguns de seus argumentos fixados dentro de seu escopo. Uma função com alguns de seus argumentos fixados é dita parcialmente aplicada.

Consegue ver a diferença? Funções curried recebem um argumento de cada vez, funções parcialmente aplicadas podem receber múltiplos argumentos de cada vez.

Algumas bibliotecas de programação funcional dão a capacidade de transformar funções que recebem múltiplos parâmetros em funções que podem ser parcialmente aplicadas.

Algo que pode gerar confusão: essas libs geralmente chamam essa função de curry. Um exemplo disso é o ramda e sua função curry

const { curry } = require('ramda')const add = curry((x, y) => x + y)add(1)(2) === add(1, 2) // true

Repare: Podemos invocar com um parâmetro de cada vez ou ambos ao mesmo tempo diferentemente do exemplo curried da seção anterior onde só podíamos invocar com um de cada vez exclusivamente.

Agora vamos pensar numa função de três argumentos

const { curry } = require('ramda')const add3 = curry((x, y, z) =>
x + y + z
)
// como `curry` permite aplicação parcial:add3(1)(2)(3) // um de cada vez > 6
add3(1, 2)(3) // dois depois um > 6
add3(1)(2, 3) // um depois dois > 6
add3(1, 2, 3) // todos de uma vez > 6

Observação: É possível implementarmos nossa versão de curry da seguinte forma. Explicar os detalhes dela está fora do escopo, mas como se pode ver, é uma higher order function que é recursiva e finaliza a recursão invocando a função original com todos os parâmetros quando todos são fornecidos.

const curry = (fn, n) => {  
const arity = n || fn.length
return (...params) =>
params.length >= arity
? fn(...params)
: curry(
(...rest) => fn(...params, ...rest),
arity - params.length
)
}

Agora voltando ao assunto de aplicação parcial. Porque faríamos algo do tipo?

4. Composição facilitada

Problema: Imagine que queremos calcular a soma da idade de todos usuários administradores — exemplo bem simples e artificial, mas ajuda a ilustrar — e um usuário é representado da seguinte forma:

/*
{
admin: Boolean, // flag indicando que é administrador
age: Number, // idade em anos
// outras propriedades omitidas
}
*/

// para facilitar construir os exemplos, vamos definir um construtor
// que recebe somente os parâmetros que nos interessa
const User = (age, admin) => ({
age,
admin,
})

Antes de tentarmos resolver o problema, vamos primeiro falar sobre composição de funções vamos falar brevemente — e informalmente — sobre composição de funções.

Assumindo que temos duas funções f e g:

h = f . g
h é a composição de f com g, ou seja,
h(x) = f(g(x))

Vamos assumir que em javascript temos um função compose que recebe funções e retorna a composição delas

const h = compose(f, g)

Agora vamos extender isso para um número arbitrário de funções

// f = f1 . f2 . (...) . fN 
// f é a composição de f1 com f2 com (...) fN
// f(x) = f1( f2( ... fN(x) ... ) )
const f = compose(f1, f2, /* ..., */ fN)

A função f é a composição das outras funções.

Note que a ordem de execução das funções compostas é da direita pra esquerda — ao invocar f(x), o resultado de fN(x) é passado como parâmetro para a função anterior e assim sucessivamente até invocar f1 com o resultado até então — por conta da regra matemática de composição de funções.

Em javascript podemos escrever a função compose da seguinte forma

const compose = (...fns) => x =>  
fns.reduceRight((y, f) => f(y), x)

Observação: libs de programação funcional já vem com o compose implementado como o compose do ramda, por exemplo.

Existe um jeito um pouco mais prático de se pensar e talvez mais fácil de ler e entender: podemos pensar em composição de funções como um pipeline de funções que vão transformando os argumentos.

Vamos assumir que em javascript temos a função pipe — denotado na forma do operador |> nos seguintes exemplos — que funciona da seguinte forma

// h = f |> g
// h(x) = g(f(x))
// h é o pipeline de f e g
const h = pipe(f, g)// f = f1 |> f2 |> (...) |> fN
// f(x) = fN( ... f2( f1(x) ) ... )
// f é o pipeline de f1, f2, ... fN
const f = pipe(f1, f2, /* ... */ fN)

Note que, ao contrário de compose a ordem de execução das funções no pipeline é da esquerda para a direita o que pode facilitar a leitura e entendimento, mas o efeito é o mesmo: construímos uma nova função que consiste das funções compostas passando o retorno como parâmetro para a próxima sucessivamente.

Em javascript podemos escrever a função pipe da seguinte forma

const pipe = (...fns) =>
x => fns.reduce((v, f) => f(v), x)

Observação: libs de programação funcional geralmente vem com uma implementação de pipe como pipe do ramda, por exemplo.

De volta a nosso problema. Agora temos o conhecimento e as ferramentas para atacá-lo. Primeiramente, vamos utilizar ajuda de nossos velhos amigos as higher order functions de array, mas dessa vez, vamos utilizá-las na forma parcialmente aplicada:

const map = curry((mapper, array) =>
array.map(mapper)
)
const filter = curry((predicate, array) =>
array.filter(predicate)
)
const reduce = curry((reducer, initial, array) =>
array.reduce(reducer, initial)
)

Vamos também utilizar nossa função de adição

const add = curry((x, y) => x + y)

Legal. Com todas essas ferramentas em mãos, podemos criar nossas funções de negócio para resolver nosso problema.

Vamos escrever nossas funções de domínio

// Determina se o usuário é admin
const isAdmin = user => user.admin
// Pega a idade de um usuário
const getAge = user => user.age

Com isso em mãos, podemos resolver nosso problema com

// [User] -> Number
const sumAdminAges = pipe(
filter(isAdmin), // [User] -> [User] (somente admins)
map(getAge), // [User] -> [Number] (idades)
reduce(add, 0) // [Number] -> Number (soma)
)
// (ou com compose ao invés de pipe)const sumAdminAges = compose(
reduce(add, 0),
map(getAge),
filter(isAdmin)
)

Olha só! Utilizando essas pequenas etapas temos nossa função completa. Cada etapa é uma função parcialmente aplicada.

Podemos melhorar o código ainda mais. Note como isAdmin e getAge são similares: ambas acessam uma propriedade de um objeto. Vamos abstrair isso para uma (adivinha só) função parcialmente aplicável.

const prop = curry((field, object) =>
object[field]
)
// Dessa forma podemos reescrever nossas funções.
// Note que parcialmente aplicamos o nome do campo acessado.
const isAdmin = prop('admin') // user -> user['admin']const getAge = prop('age') // user -> user['age']

Outra pequena refatoração: somar um array de números é algo genérico o suficiente para ter sua própria função:

const sum = reduce(add, 0)

Com isso temos nossa versão final:

const sumAges = pipe(
filter(isAdmin),
map(getAge),
sum
)

Note que todas as funções recebem o objeto ou array que será transformado como o último parâmetro da função. O nome dessa abordagem é data last e é aplicada para justamente facilitar a composição das funções como um grande pipeline de transformações.

Porque isso é legal? Bom, vamos olhar algumas alternativas imperativas.

const sumAgeAdmins = (users) => {
let sum = 0
for (const user of users) {
if (user.admin) {
sum += user.age
}
}
return sum
}
// ou (discutivelmente pior)const sumAgeAdmins = (users) => {
let sum = 0
for (let i = 0; i < users.length; i ++) {
const user = users[i]
if (user.admin) {
sum += user.age
}
}
return sum
}

Em minha opinião, a abordagem de composição de funções é a mais legível: é declarativa e cada etapa ou transformação é clara tornando fácil de entender o problema que quer resolver. Além disso, é o mais fácil de se adicionar funcionalidades, tão fácil quanto compor com outras funções.

4.1 Point-free e debug

Apesar dessas vantagens, essa abordagem declarativa pode ser a mais difícil de se debuggar.

Você provavelmente notou que nas nossas funções de regra de negócio não há referência aos parâmetros das funções:

const getAge = prop('age') // não temos referência ao userpipe(
// ...
filter(isAdmin) // não temos referência ao array de users
)

Isso é chamada de point-free style. Como deu pra perceber, dado que nossas funções não tem referência aos parâmetros ou sequer um corpo em forma de bloco, não temos onde adicionar breakpoints para debuggar o que pode instigar a prática de "print debugging" (logar as coisas ao invés de de fato debuggar — o que eu desencorajo, debuggers são nossos amigos) ou a temporariamente remover o estilo point-free:

pipe(
// ...
users => // agora eu tenho todas essas
filter(user => // linhas para adicionar breakpoints
isAdmin(user), users // e inspecionar os valores
)
)

Quando há necessidade de investigação, eu prefiro o uso do debugger e acabo fazendo exatamente isso que está aí em cima — só não esqueça de recolocar o point-free antes de commitar o código…eu já fiz isso por acidente.

Mas como dito, é bastante fácil de se adicionar funcionalidades, então vamos adicionar um jeito de logar e fazer o "print debugging" de nossas funções:

const log = curry((label, value) => {
console.log(`[${label}]`, value)
return value
})

Escrevemos uma função que loga o valor recebido com uma label de identificação e então retorna o mesmo valor recebido. Por conta disso, conseguimos compor essa função sem problemas.

const sumAdminAges = pipe(
log('Lista de users') // [User] -> [User]
filter(isAdmin),
log('Users admin'), // [User] -> [User]
map(getAge),
log('Idades'), // [Number] -> [Number]
sum,
)

Note que agora podemos logar as listas intermediárias que são passadas de função pra função.

Mas e se quiséssemos logar cada usuário que é passado para a função getAge ? Composição, é claro!

pipe(
// ...
map(compose(getAge, log('extraindo a idade do user')))
// ...
)

Agora conseguimos "debuggar" e tudo continua declarativo (e point-free).

4.2 Comparação com o estilo imperativo

O jeito imperativo de resolver esse problema é, na minha opinião, o mais difícil de compreender. Não somente tem blocos aninhados, mas também faz uso de estado mutável (let sum = 0) que deixa a lógica espalhada (no bloco mais aninhado, uma variável declarada no escopo mais externo é mutada).

Observação 1: Esse foi um exemplo simples, mas manter o controle de estado mutável pode ser extremamente difícil dependendo da situação, especialmente quando execução concorrente é envolvida.

Inspirado numa pergunta de um participante: Onde não usar essa abordagem? Essa foi uma excelente pergunta. Isso é uma ferramenta como todas as outras, precisamos saber quando aplicar.

Algumas situações onde utilizar essa abordagem pode não ser legal:

  • Quando não conseguimos escrevermos nosso código como um pipeline de transformações — o ideal é que modelemos nossas soluções para tal, escolhendo as estruturas de dados adequadas como nosso dado propagado e transformado pelo pipeline — para essas situações, pode ser mais simples escrever num formato sequencial mais imperativo (usando async/await ao invés de cadeia de Promises por exemplo — algumas linguagens que são funcionais inclusive vem com sintaxe pra facilitar escrever sequencialmente mesmo num "cenário funcional", como o do notation do Haskell ou for comprehension do Scala. Esse "cenário funcional", sendo mais específico trata-se de composição sequencial de Monads que não é escopo desse workshop, mas sendo bem simplista, dá pra fazer analogia com o async/await do Javascript).
  • Algoritmos que dependem de mutabilidade e declarações imperativas para serem otimizados em tempo e espaço.

5. Aplicação

Antes de falarmos da aplicação vamos primeiro recapitular o que vimos até agora.

Higher order functions são funções que recebem funções como argumento ou retornam funções quando invocadas.

Curried functions são higher order functions que recebem um argumento de cada vez retornando uma série de funções até que todos argumentos sejam aplicados.

Aplicação parcial é quando uma função tem alguns de seus argumentos fornecidos.

Composição de funções pode ser facilitada utilizando-se de funções curried e aplicação parcial.

Agora vamos a aplicação!

O desafio

Nossa aplicação será um crawler de uma API de catálogo de produtos que são bebidas. A idéia é conseguirmos armazenar — no caso vamos simplesmente logar no console — todos os produtos que a API alvo do nosso processo disponibiliza de uma forma performática.

Para poder fazer isso precisaremos de alguns detalhes sobre essa API para podermos determinar como conseguiremos buscar todos os produtos: quais são os endpoints? qual o contrato deles — parâmetros, tipo de retorno — ?

A API de produtos

A API de catálogo de produtos consiste de uma API REST que disponibiliza os dados em formato JSON. Ela tem três funcionalidades, cada uma representada por um endpoint:

  • Listagem de categorias de produtos
exemplo de request de listagem de categorias
  • Listagem paginada de produtos por categoria — somente id e nome
exemplo de request de listagem paginada de
  • Detalhes de um produto por id
Exemplo de request de detalhes de produto

Legal! Temos os endpoints, podemos já começar a escrever nosso crawler, certo? Não tão rápido. Infelizmente a API tem algumas particularidades com as quais teremos que lidar para que nossa aplicação não exploda na nossa cara, mais especificamente, a API tem restrições de rate limit:

  • Endpoint de listagem de produtos pode receber no máximo duas requests simultâneas da mesma origem
  • Endpoint de detalhes de produto pode receber somente uma request a cada 100ms da mesma origem

Observação 1: essas restrições são completamente artificiais: a API disponibilizada no repositório do workshop não implementa rate limiting de fato, vamos ter que fingir somente. Tipicamente quando rate limiting é atingido, o cliente recebe um erro com status http 503.

Observação 2: Não entraremos no mérito da ética de crawlers/scrapers, mas mesmo que a API alvo não tivesse rate limit, deveríamos nos preocupar com não sobrecarregar a infraestrutura dela com requests de "crawleamento", isso seria basicamente um mini ataque de DoS.

Legal, mas como isso afeta nossa solução exatamente? Bom, se não houvesse restrições de rate limit, poderíamos fazer o máximo número de requisições concorrentes a API para fazer o processo o mais rápido possível. Mas não é o fim do mundo, apesar de não podermos utilizar o máximo de concorrência permitida pelo nosso hardware, ainda conseguimos utilizar concorrência, só teremos que limitá-la de acordo com os requisitos.

A solução

Vamos pensar primeiro na solução utópica onde podemos fazer quantas requests "paralelas" quisermos. Vamos também pensar na solução como um pipeline de transformações, assim como vimos previamente.

  1. Listar todas as categorias, produzindo uma lista de ids de categorias
  2. Listar todos os produtos de cada categoria por id "paralelamente", produzindo uma lista de ids de produtos
  3. Buscar o detalhe de cada produto por id "paralelamente", produzindo uma lista de produtos detalhados
  4. Salvar a lista de produtos

Legal! Temos as etapas de nossa solução nuclear determinados. Podemos adaptá-las para a solução correta.

  1. Listar todas as categorias, produzindo uma lista de ids de categorias
  2. Listar todos os produtos de cada categoria por id duas categorias de cada vez, produzindo uma lista de ids de produtos
  3. Sequencialmente, com delay de 100ms, para cada id de produto, buscar seu detalhe, produzindo uma lista de produtos detalhados
  4. Salvar a lista de produtos

Finalmente temos nossa solução, agora podemos colocar a mão na massa.

O crawler concorrente

Observação: para não nos prolongarmos muito, vou assumir que as funções responsáveis por executar as requests a API já estão feitas. Para ver a implementação das mesmas, basta olhar no repositório base do workshop.

Para facilitar nossa solução, utilizaremos o ramda para nos prover as facilidades funcionais.

Para implementarmos nossa solução, precisaremos implementar algumas funções auxiliares:

  • concurrently: executa de forma concorrente funções de transformação — retornam Promises — em uma lista com um limite de concorrência. Utilizaremos a dependência pMap para implementá-la.
  • concatAll: dada uma lista de listas, concatena todas e retorna a lista concatenada.
  • delay: "dorme" por um tempo dado por um valor em milissegundos (retorna uma Promise que resolve depois do tempo de espera).
  • delayed: "dorme" por um tempo em milissegundos antes de retornar um valor (retorna uma Promise que se resolve com o valor depois do tempo de espera).
  • serially: dada uma lista e função de transformação que retorna uma Promise, executa o map sequencial pela lista — para cada elemento executa a transformação, mas só executa o próximo elemento após a promise do anterior concluir.
  • then: facilita a composição de funções que retornam Promises — vai ser somente um alias com nome mais curto para o andThen do ramda.

Agora temos as ferramentas pra escrevermos nosso crawler de acordo com os passos da solução.

Excelente! Como se pode ver, nosso crawler é um pipe — composição — de funções, declarativo e legível!

Conseguimos resolver um problema não trivial e mesmo assim nosso código não compromete a legibilidade e entendimento.

O fato de estar declarativo facilita muito a leitura, chega a ser natural:

  • lista os ids das categorias
  • concorrentemente, com no máximo dois simultâneos, lista os ids de produtos para cada id de categoria
  • concatena as listas de ids de produtos em uma única
  • sequencialmente busca o detalhe de produto para cada id de produto, com um delay de 100ms para cada chamada
  • salva os produtos (loga e retorna)
  • loga o tamanho da lista

Conclusão

Chegamos ao fim do nosso workshop que lidou com algumas técnicas de programação funcional.

Utilizando-as, pudemos produzir código declarativo e muito legível mesmo resolvendo um problema não trivial.

Mas atente-se: o objetivo do workshop não é dizer que uma abordagem é melhor do que a outra. Paradigmas, assim como linguagens, libs, frameworks, etc são ferramentas. Cabe a nós saber onde e como utilizá-los a nosso favor.

Espero que com esse workshop, você tenha adquirido mais uma ferramenta para seu cinto de utilidades além de conhecimento sobre alguns assuntos da programação funcional.

Pós workshop

Repetindo da seção "pós workshop" do README do repositório base:

Código produzido

Todo código produzido nesse workshop está disponível no branch cheat-sheet dentro do diretório workshop segmentado em um arquivo por etapa.

PS: exceto pelo arquivo workshop/4-challenge que contém o crawler concorrente, os outros arquivos são um catadão de funções utilizadas durante o workshop, os arquivos não necessariamente executam corretamente i.e. use como referência, não como scripts.

Lição de casa

1 . Tente implementar sua própria versão da função concurrently apresentada no workshop sem utilizar a dependência pMap
Dicas: as funções Promise.all, reduce e splitEvery - vinda do ramda para fazer chunks de lista - são suas amigas; entender como o serially funciona vai ajudar

2 . Pense como seria se a API tivesse algum mecanismo de autenticação e os endpoints fossem autenticados via token.

2.1 . Assumindo que o token tem duração maior do que o processo como um todo, como poderíamos transmitir esse token internamente para nossas funções que executam as requests sem usar variáveis mutáveis?

2.2 . Assumindo que o token pode expirar durante o processo, como poderíamos lidar com isso?

Referências e comentários

  • Esse workshop foi inspirado nesse artigo que escrevi há um tempo atrás que fala sobre esses conceitos de programação funcional. Dentro dele há diversas referências a outros artigos que lidam com cada conceito individualmente e com mais profundidade, recomendo a leitura deles também.
  • Recomendo também a leitura do README do repositório base. Nele além de ter instruções sobre como executar e acompanhar o workshop, links para os códigos produzidos, também tem alguns exercícios pra praticar em casa.
  • Tive a oportunidade de dar outro workshop no evento e criei um artigo para ele também: Containerizando o ambiente usando Docker e docker-compose
  • Gostaria de agradecer a minha grande amiga Thaíssa Candella por ter me convidado a participar do evento.
  • Gostaria de agradecer também o André Kanayama meu amigo e colega de Zé Delivery por ter participado dessa saga comigo e ter produzido conteúdos de altíssima qualidade: Entendendo a autenticação com JWT e Criando uma API REST escalável usando Serverless, API Gateway e DynamoDB — recomendo a todos.
  • Gostaria de agradecer também o Paulo Duarte — que também produziu workshop sensacional sobre CI/CD de aplicação python usando Github Actions — e Adrian Shiokawa por todo apoio, por assistirem os workshops e por ficarem causando com a gente durante.
  • Gostaria também de agradecer a todos do Zé Delivery que nos apoiaram torcendo por nós e dando o suporte necessário para que os workshops fossem possíveis.

Muito obrigado por ter participado do workshop!

--

--