Пошаговое руководство по созданию бессерверного API Go
Благодаря недавней поддержке Go на AWS Lambda я начал возиться с созданием различных бессерверных веб-приложений. По мере того, как приложения становились более сложными (требующими более 1 или 2 маршрутов), компоновка проекта, локальная разработка и развертывание становились все более сложными. Узнав об этих болевых точках, я составил это пошаговое руководство для тех, кто заинтересован в быстром создании и запуске настоящего приложения.
Что мы будем строить
В этой статье вы узнаете, как создать шину событий и развернуть ее в AWS. Наш автобус для мероприятий будет иметь 3 маршрута, которые позволят вам:
POST /subscriptions
- типы событий подписки для маршрутизации в конечную точку. Этот маршрут будет принимать параметрыevent_type
иendpoint
.POST /events
- Доставить данные о событии всем применимым конечным точкам подписки с помощью POST. Этот маршрут принимаетevent_type
иpayload
.DELETE /subscriptions/{id}
- Удалить подписку по ID.
Чтобы гарантировать, что не каждый может использовать нашу шину событий, мы также добавим аутентификацию, проверив значение заголовка авторизации.
Как мы можем легко справиться со всем этим?
Мы будем использовать несколько интересных технологий для создания и развертывания нашей шины событий. AWS Lambda и AWS API Gateway будут использоваться для размещения и выполнения нашего кода. Для обработки аутентификации и простой рендеринга ответов для наших маршрутов мы будем использовать Lambda Go API Proxy для использования Gin Gonic.
Для локальной разработки мы можем использовать AWS SAM CLI (далее мы будем называть его SAM local). Этот инструмент будет имитировать Lambda и API-шлюз, поэтому вы можете взаимодействовать с приложением, нажимая localhost: 3000, вместо того, чтобы выполнять развертывание для просмотра каждого изменения.
Для развертывания нашего приложения мы будем использовать Serverless, который позволяет нам развертывать с помощью одной команды. Serverless упрощает определение маршрутов, параметров пути и переменных среды для использования в нашей живой среде через файл serverless.yml.
Структура проекта
API Gateway будет передавать детали HTTP-запроса в Lambda, который выполнит один двоичный файл для обработки входящих данных и передачи ответа. Чтобы удовлетворить эту архитектуру, наше веб-приложение будет состоять из 3 двоичных файлов, каждый из которых импортирует общий пакет Go. Все наши модели аутентификации, бизнес-логики и баз данных будут содержаться в пакете с подходящим названием eventbus
.
Этот макет очень похож на стандартный макет проекта Go CMD и для очень сложных веб-приложений, безусловно, стоит проверить. Для целей этого проекта мы будем организовывать наши двоичные файлы по их общедоступным путям и действиям. endpoints/events/create/main.go
будет обрабатывать POST /events
маршрут и будет скомпилирован в bin/events/create
.
Пакет event
bus
База данных
Данные - это любовь, данные - это жизнь, без данных в Интернете ничего не происходит. Учитывая эту мудрую пословицу, которую я только что придумал, давайте настроим наш код подключения к базе данных в eventbus/database.go
. Мы будем использовать Gorm, диспетчер отношений объектов на основе Go, для обработки подключения к базе данных, моделей, запросов и миграций.
Создайте новый файл eventbus/database.go
и добавьте следующий код:
package eventbus import ( "os" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/postgres" ) var db *gorm.DB var err error func init() { db, err = gorm.Open( "postgres", "host="+os.Getenv("DATABASE_HOST")+ " user="+os.Getenv("DATABASE_USER")+ " dbname="+os.Getenv("DATABASE_NAME")+ " password="+os.Getenv("DATABASE_PASSWORD")+ " sslmode=disable"+ " connect_timeout=5") if err != nil { panic(err) } db.LogMode(true) db.AutoMigrate(&Event{}) db.AutoMigrate(&Subscription{}) }
Это довольно просто. Функция init()
будет вызываться при запуске приложения. Мы откроем соединение с базой данных PostgreSQL и сделаем его доступным для всех других файлов в пакете eventbus
, сохранив его в глобальной переменной db
. Два вызова db.AutoMigrate
создадут таблицы для размещения двух наших основных структур. В рамках данной статьи предполагается, что мы работаем с базой данных RDS PostgreSQL.
Давайте начнем использовать наше соединение с базой данных с создания структуры подписки! Если вы помните, в структуре подписки будет поле event_type
и поле endpoint
, которые оба будут строками. При добавлении gorm.Model
в столбцы структуры подписки для id, created_at, updated_at и deleted_at будут автоматически добавлены в таблицу subscriptions
. Поскольку мы будем запрашивать подписки до event_type
, имеет смысл добавить индекс в это поле.
Создайте новый файл eventbus/subscriptions.go
и добавьте следующий код:
package eventbus import ( "github.com/jinzhu/gorm" ) type Subscription struct { gorm.Model EventType string `json:"event_type" sql:"index"` Endpoint string `json:"endpoint"` }
Подписки
Выглядишь круто. Поскольку мы планируем поддерживать маршрут для создания подписок и маршрут для удаления подписок, мы должны добавить функции для обработки этого. Обе эти функции просты.
CreateSubscription
принимает две строки, тип события и конечную точку, и заполняет новую структуру подписки, вставляет ее в базу данных и возвращает ее.
DeleteSubscription
примет идентификатор в виде строки, вытащит запись из базы данных, удалит ее, а затем вернет подписку. В случае, если запись не была найдена, будет возвращена пустая структура подписки.
Добавьте следующий код в eventbus/subscriptions.go
.
func CreateSubscription(eventType string, endpoint string) Subscription { subscription := Subscription{ EventType: eventType, Endpoint: endpoint, } db.Create(&subscription) return subscription } func DeleteSubscription(id string) Subscription { var subscription Subscription db.First(&subscription, id) db.Delete(&subscription) return subscription }
События
Аналогом структуры Subscription будет наша структура Event. Событие будет состоять из EventType
и Payload
, которые мы будем хранить в виде строк.
Создайте новый файл eventbus/events.go
и добавьте следующий код:
package eventbus import ( "github.com/jinzhu/gorm" ) type Event struct { gorm.Model EventType string `json:"event_type"` Payload string `json:"payload"` }
Поскольку мы будем поддерживать только создание маршрута для событий, нам нужно будет добавить только одну дополнительную функцию. Функция CreateEvent
принимает тип события и полезную нагрузку как строки. Эти параметры будут использоваться для заполнения новой структуры события, которая затем будет сохранена в базе данных и возвращена.
Добавьте следующий код в eventbus/events.go
.
func CreateEvent(eventType string, payload string) Event { event := Event{ EventType: eventType, Payload: payload, } db.Create(&event) startDeliveries(&event) return event }
Доставка
Проницательный читатель заметит, что startDeliveries
, который вызывается в предыдущем блоке кода, еще не написан. Основная функция шины событий - доставлять события подписчикам. Таким образом, давайте добавим бизнес-логику, передающую данные о событиях всем применимым подписчикам.
Создайте новый файл eventbus/deliveries.go
и добавьте следующий код:
package eventbus import ( "bytes" "encoding/json" "fmt" "net/http" "time" ) func startDeliveries(event *Event) { var subscriptions []Subscription db.Where("event_type = ?", event.EventType).Find(&subscriptions) for _, subscription := range subscriptions { deliverEvent(event, subscription.Endpoint) } } type Payload struct { Payload string `json:"payload"` } func deliverEvent(event *Event, path string) { payload := Payload{ Payload: event.Payload, } data, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", path, bytes.NewBuffer(data)) req.Header.Set("Content-Type", "application/json") client := &http.Client{ Timeout: time.Second * 30, } resp, _ := client.Do(req) fmt.Println(resp) }
Наша функция startDeliveries
, которая принимает указатель на событие, будет запрашивать в базе данных всех подписчиков на тип события. Затем мы переберем возвращенные подписки и передадим событие и конечную точку подписки в функцию deliverEvent
.
Функция deliverEvent
берет строку данных события, помещает ее в структуру Payload
и отправляет в конечную точку подписки. Чтобы наше приложение не зависало на неотвечающих конечных точках, мы добавим 30-секундный тайм-аут при выполнении исходящего запроса. Наконец, ответ будет напечатан, чтобы мы могли увидеть, чем ответила конечная точка.
Маршрутизация и аутентификация
Имея в наличии нашу бизнес-логику, пора подумать о том, как мы будем управлять входящими запросами. Как упоминалось ранее, я счел полезным использовать AWS Lambda Go Api Proxy вместе с Gin для обработки входящих параметров, аутентификации и рендеринга ответов. Когда вызывается одна из наших лямбда-функций, мы создаем глобальный экземпляр ginadapater.GinLambda
и используем его для обработки всех входящих запросов от API Gateway. ginadapter
принимает ginEngine
, для которого, чтобы наш код оставался сухим, мы построим фабрику.
Наша фабрика должна будет создать gin.Engine
три вещи: путь, метод HTTP и функция, которую мы хотим обработать запрос. Функция MountAuthorizedRoute
инициализирует механизм, присоединяет обработчик аутентификации и настраивает механизм, используя путь, метод HTTP и функцию обработчика, которые мы предоставляем.
Обработчик аутентификации проверит, что входящий запрос содержит значение заголовка аутентификации, которое соответствует переменной среды. Если токен аутентификации отсутствует, будет возвращена ошибка, в которой говорится, что это необходимо. Если токен не совпадает, мы вернем сообщение об этом.
Создайте новый файл eventbus/routes.go
и добавьте следующий код:
package eventbus import ( "os" "github.com/gin-gonic/gin" ) func MountAuthorizedRoute(path string, method string, fn gin.HandlerFunc) *gin.Engine { engine := buildEngine() group := engine.Group("/") group.Use(authorizedHandler()) setMethodHandlerForGroup(method, path, fn, group) return engine } func buildEngine() *gin.Engine { engine := gin.New() engine.Use(gin.Logger()) engine.Use(gin.Recovery()) return engine } func setMethodHandlerForGroup(method string, path string, fn gin.HandlerFunc, group *gin.RouterGroup) { switch method { case "post": { group.POST(path, fn) } case "delete": { group.DELETE(path, fn) } } } func respondWithError(code int, message string, c *gin.Context) { resp := map[string]string{"error": message} c.JSON(code, resp) c.Abort() } func authorizedHandler() gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { respondWithError(401, "Authorization token required", c) return } if token != os.Getenv("AUTHENTICATION_TOKEN") { respondWithError(401, "Invalid Authorization token", c) return } c.Next() } }
Поскольку этот файл не использует функциональные возможности других частей пакета eventbus
, мы могли бы извлечь его в свой собственный routing
пакет; однако для простоты я решил добавить его в пакет eventbus
.
Похлопайте себя по плечу - основной пакет, который будут использовать наши лямбда-функции, готов!
Подключение маршрутов
После всей работы по созданию нашего основного пакета создание обработчиков API Gateway / Lambda будет казаться неприятным. Повторюсь, API Gateway передаст входящий HTTP-запрос в функцию Lambda, которая выполнит двоичный файл, который может обработать запрос и вернуть ответ. Поскольку у нас будет 3 маршрута, мы создадим 3 отдельных приложения, которые все относительно небольшие. Код для этих приложений будет находиться во вложенных папках endpoints/
в одном файле main.go
, который будет монтировать маршрут, обрабатывать запросы и возвращать ответы.
Создать подписку
Первое конечное приложение, которое мы создадим, будет обрабатывать запросы на создание новых подписок. Функция main()
привяжет функцию Handler
для выполнения всеми входящими вызовами Lambda. В свою очередь, функция Handler
будет использовать функцию MountAuthorizedRoute
, которую мы определили в eventbus
, для монтирования нашего маршрута / subscriptions к функции processRequest
.
Создайте новый файл endpoints/subscriptions/create/main.go
и добавьте следующий код:
package main import ( "net/http" "github.com/aleccarper/serverless-eventbus/eventbus" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" "github.com/awslabs/aws-lambda-go-api-proxy/gin" "github.com/gin-gonic/gin" ) var initialized = false var ginLambda *ginadapter.GinLambda func Handler(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { if !initialized { ginEngine := eventbus.MountAuthorizedRoute("/subscriptions", "post", processRequest) ginLambda = ginadapter.New(ginEngine) initialized = true } return ginLambda.Proxy(req) } type Input struct { EventType string `form:"event_type" json:"event_type" binding:"required"` Endpoint string `form:"endpoint" json:"endpoint" binding:"required"` } func processRequest(c *gin.Context) { var input Input c.BindJSON(&input) subscription := eventbus.CreateSubscription(input.EventType, input.Endpoint) c.JSON(http.StatusCreated, subscription) } func main() { lambda.Start(Handler) }
Для меня путь импорта - github.com/aleccarper/serverless-eventbus/eventbus
, но вам нужно будет изменить его на относительный путь к вашему пакету eventbus
.
При выполнении processRequest
ему будет передана информация о входящем запросе через параметр *gin.Context
. Мы свяжем входящий JSON с новой структурой ввода, а затем передадим event_type
и endpoint
функции CreateSubscriptions
, которую мы создали в пакете eventbus
.
После того, как структура подписки будет создана и возвращена CreateSubscriptions
, мы вернем ее как JSON обратно в gin.Context
со статусом HTTP 201.
Удалить подписки
Приложение, которое будет обрабатывать удаление подписок, будет почти идентично приложению для создания подписок. Создайте новый файл endpoints/subscriptions/delete/main.go
и скопируйте / вставьте код, который мы написали для создания подписки. Вы можете продолжить и удалить структуру Input
.
Нам нужно будет изменить путь и метод HTTP, используемые при монтировании ginEngine. Изменять:
ginEngine := eventbus.MountAuthorizedRoute("/subscriptions", "post", processRequest)
to:
ginEngine := eventbus.MountAuthorizedRoute("/subscriptions/:id", "delete", processRequest)
и обновите processRequest
до:
func processRequest(c *gin.Context) { subscription := eventbus.DeleteSubscription(c.Param("id")) c.JSON(http.StatusOK, subscription) }
Создать События
Последнее приложение будет обрабатывать создание событий, и оно также будет почти идентично двум предыдущим. Создайте новый файл endpoints/subscriptions/delete/main.go
и скопируйте / вставьте код, который мы написали для приложения для создания подписки.
Нам нужно будет изменить путь и метод HTTP, используемые при монтировании ginEngine. Измените монтажный код на:
ginEngine := eventbus.MountAuthorizedRoute("/events", "post", processRequest)
и обновите Input
и processRequest
, чтобы:
type Input struct { EventType string `form:"event_type" json:"event_type" binding:"required"` Payload string `form:"payload" json:"payload" binding:"required"` } func processRequest(c *gin.Context) { var input Input c.BindJSON(&input) event := eventbus.CreateEvent(input.EventType, input.Payload) c.JSON(http.StatusCreated, event) }
И на этом мы закончили писать код! Давайте раскрутим этого плохого парня локально, чтобы мы могли протестировать нашу шину событий перед ее развертыванием.
Запуск нашей шины событий локально
Команда AWS собрала фантастический инструмент для локальной разработки приложений Lambda / API Gateway под названием AWS SAM CLI, и мы будем его использовать.
На момент написания этой статьи существует ошибка в текущем выпуске CLI, которая не позволяет запускать двоичные файлы Go. Чтобы избежать этого, вы можете установить предыдущую версию интерфейса командной строки через NPM, запустив npm install -g aws-sam-local
.
Настройка SAM CLI
SAM использует файл конфигурации, который описывает все ваши лямбда-функции. Вы можете использовать этот файл для сопоставления URL-адресов с двоичными файлами и определения других параметров, таких как переменные среды.
Создайте новый файл template.yaml
и добавьте следующий код:
AWSTemplateFormatVersion : '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: An example RESTful service Resources: EventsCreate: Type: AWS::Serverless::Function Properties: Handler: main CodeUri: ./bin/events/create.zip Runtime: go1.x Timeout: 30 Environment: Variables: DATABASE_USER: DATABASE_PASSWORD: DATABASE_HOST: DATABASE_NAME: AUTHENTICATION_TOKEN: Events: GetRates: Type: Api Properties: Path: /events Method: post SubscriptionsCreate: Type: AWS::Serverless::Function Properties: Handler: main CodeUri: ./bin/subscriptions/create.zip Runtime: go1.x Timeout: 30 Environment: Variables: DATABASE_USER: DATABASE_PASSWORD: DATABASE_HOST: DATABASE_NAME: AUTHENTICATION_TOKEN: Events: GetRates: Type: Api Properties: Path: /subscriptions Method: post SubscriptionsDelete: Type: AWS::Serverless::Function Properties: Handler: main CodeUri: ./bin/subscriptions/delete.zip Runtime: go1.x Timeout: 30 Environment: Variables: DATABASE_USER: DATABASE_PASSWORD: DATABASE_HOST: DATABASE_NAME: AUTHENTICATION_TOKEN: Events: GetRates: Type: Api Properties: Path: /subscriptions/{id} Method: delete
Переменные среды
Вы заметите, что переменные среды в файле конфигурации пусты - это сделано намеренно, так как вы захотите зафиксировать файл шаблона в системе управления версиями. К счастью, мы можем динамически загружать переменные среды из файла env.json
при запуске приложения SAM. Файл env.json
можно добавить в .gitignore
.
Создайте новый файл env.json
и добавьте следующий код:
{ "EventsCreate": { "AUTHENTICATION_TOKEN": "lemme in", "DATABASE_USER": "mydatabaseuser", "DATABASE_PASSWORD": "mydatabasepassword", "DATABASE_HOST": "mydatabasehouse", "DATABASE_NAME": "mydatabasename" }, "SubscriptionsCreate": { "AUTHENTICATION_TOKEN": "lemme in", "DATABASE_USER": "mydatabaseuser", "DATABASE_PASSWORD": "mydatabasepassword", "DATABASE_HOST": "mydatabasehouse", "DATABASE_NAME": "mydatabasename" }, "SubscriptionsDelete": { "AUTHENTICATION_TOKEN": "lemme in", "DATABASE_USER": "mydatabaseuser", "DATABASE_PASSWORD": "mydatabasepassword", "DATABASE_HOST": "mydatabasehouse", "DATABASE_NAME": "mydatabasename" } }
Измените все переменные подключения к базе данных, чтобы они соответствовали вашему экземпляру RDS.
Makefile
Прежде чем мы сможем запустить SAM, нам нужно будет собрать и заархивировать наши приложения для конечных точек. Самый простой способ сделать это - использовать Makefile, поэтому мы можем просто запустить make
в любое время, когда будет внесено изменение. Большинство IDE также поддерживают выполнение файла, такого как make-файл, при каждом сохранении файла.
Создайте новый файл Makefile
и добавьте следующий код:
build: dep ensure env GOOS=linux go build -ldflags="-s -w" -o main endpoints/events/create/main.go mkdir -p bin/events zip bin/events/create.zip main mv main bin/events/create env GOOS=linux go build -ldflags="-s -w" -o main endpoints/subscriptions/create/main.go mkdir -p bin/subscriptions zip bin/subscriptions/create.zip main mv main bin/subscriptions/create env GOOS=linux go build -ldflags="-s -w" -o main endpoints/subscriptions/delete/main.go mkdir -p bin/subscriptions zip bin/subscriptions/delete.zip main mv main bin/subscriptions/delete
Давайте разберем эти команды.
dep ensure
запустит диспетчер пакетов Go, чтобы загрузить все отсутствующие пакеты, которые мы используем. Зависимости будут сохранены в каталоге vendor
. dep
похож на bundler
для тех, кто знаком с Ruby, и hex
для тех, кто работал с Elixir.
После dep ensure
есть 3 похожих блока - по одному для каждого из наших конечных приложений. Каждый из этих блоков будет:
- Скомпилируйте исходный код в файле main.go и сохраните его в корневом каталоге проекта. В
go build
передается несколько параметров, чтобы обеспечить выполнение файла в среде Linux, которую будут использовать как SAM, так и Lambda. Бинарный файл выводится в корневой проект как «основной». - Затем создается каталог, который мы будем использовать для сопоставления двоичных файлов с URL-адресами.
- Затем мы заархивируем двоичный файл конечной точки и сохраним его во вновь созданном каталоге. Этот zip-файл используется SAM.
- Наконец, «основной» двоичный файл перемещается в тот же каталог и переименовывается. Бинарный файл используется Serverless и развертывается в AWS.
Запуск SAM
Наконец-то мы наконец-то можем создать наше бессерверное приложение и запустить его локально!
Запустите make && sam local start-api --env-vars env.json
, чтобы создать двоичные файлы конечных точек и запустить службу локального шлюза API SAM.
Когда SAM запустится, вы увидите, что все 3 наши конечные точки были смонтированы и доступны через localhost: 3000. Используя cURL, мы можем попасть в эти конечные точки и проверить их ответы:
curl -X POST \ http://localhost:3000/subscriptions \ -H 'Authorization: lemme in' \ -H 'Cache-Control: no-cache' \ -H 'Content-Type: application/json' \ -d '{ "event_type": "new_user", "endpoint": "https://gewgle.com" }' {"ID":12,"CreatedAt":"2018-06-19T03:28:43.819217562Z","UpdatedAt":"2018-06-19T03:28:43.819217562Z","DeletedAt":null,"event_type":"new_user","endpoint":"https://gewgle.com"} curl -X POST \ http://localhost:3000/events \ -H 'Authorization: lemme in' \ -H 'Cache-Control: no-cache' \ -H 'Content-Type: application/json' \ -d '{ "event_type": "new_user", "payload": "[email protected]" }' {"ID":22,"CreatedAt":"2018-06-19T03:20:20.530358392Z","UpdatedAt":"2018-06-19T03:20:20.530358392Z","DeletedAt":null,"event_type":"new_user","payload":"[email protected]"} curl -X DELETE \ http://localhost:3000/subscriptions/11 \ -H 'Authorization: lemme in' \ -H 'Cache-Control: no-cache' \ -H 'Content-Type: application/json' {"ID":12,"CreatedAt":"2018-06-19T03:28:43.819218Z","UpdatedAt":"2018-06-19T03:28:43.819218Z","DeletedAt":null,"event_type":"new_user","endpoint":"https://gewgle.com"}
Развертывание
Подобно SAM, Serverless - сервис, который мы будем использовать для автоматизации развертывания, - использует файл конфигурации для сопоставления двоичных файлов с URL-адресами.
Чтобы избежать наличия переменных среды в нашем файле конфигурации, мы сначала установим наши переменные в AWS с помощью Parameter Store. Используйте следующие команды, чтобы установить переменные среды:
aws ssm put-parameter --name
authentication--type String --value "lemme in" aws ssm put-parameter --name
database_user--type String --value mydatabaseuser aws ssm put-parameter --name
database_password--type String --value mydatabasepassword aws ssm put-parameter --name
database_host--type String --value mydatabasehost aws ssm put-parameter --name
database_name--type String --value mydatabasename
Теперь, когда у нас загружены переменные env, мы можем собрать бессерверный файл конфигурации.
Создайте новый файл serverless.yml
и добавьте следующий код:
service: serverless-eventbus provider: name: aws runtime: go1.x package: exclude: - ./** include: - ./bin/** functions: EventsCreate: handler: bin/events/create events: - http: path: /events method: post environment: AUTHENTICATION_TOKEN: ${ssm:authentication} DATABASE_USER: ${ssm:database_user} DATABASE_PASSWORD: ${ssm:database_password} DATABASE_HOST: ${ssm:database_host} DATABASE_NAME: ${ssm:database_name} SubscriptionsCreate: handler: bin/subscriptions/create events: - http: path: /subscriptions method: post environment: AUTHENTICATION_TOKEN: ${ssm:authentication} DATABASE_USER: ${ssm:database_user} DATABASE_PASSWORD: ${ssm:database_password} DATABASE_HOST: ${ssm:database_host} DATABASE_NAME: ${ssm:database_name} SubscriptionsDelete: handler: bin/subscriptions/delete events: - http: path: /subscriptions/{id} method: delete environment: AUTHENTICATION_TOKEN: ${ssm:authentication} DATABASE_USER: ${ssm:database_user} DATABASE_PASSWORD: ${ssm:database_password} DATABASE_HOST: ${ssm:database_host} DATABASE_NAME: ${ssm:database_name}
Установив переменные среды и настроив бессерверный файл конфигурации, мы можем продолжить развертывание.
Введите serverless deploy
, и вы должны получить следующий результат
Плавник
Готово - полноценное бессерверное веб-приложение. Я надеюсь, что это руководство помогло связать для вас бессерверное пространство API. Если у вас есть вопросы или комментарии, оставьте сообщение ниже!
Вам нравится писать отличный код на Ruby или Elixir? Пойдем со мной поработать в TaxJar.