Сегодня мы создадим облачную функцию на TypeScript, которая будет возвращать текущую погоду для переданного города («weather app» на лямбдах), рассмотрим основные моменты работы, покроем код тестами и задеплоим функцию в AWS Lambda.
Данный выпуск - третий по serverless технологиям в блоге, и сегодня мы поговорим о работе с TypeScript. С другими постами по теме вы можете ознакомиться по ссылкам ниже:
Есть несколько способов для работы с TS в лямбдах: собирать TypeScript-исходники с помощью ts-node, собирать с помощью webpack или использовать плагин serverless-plugin-typescript
при использовании фреймворка serverless.
Собирать вебпаком код в один бандл стоит, если размер функции со всеми хелперами и вспомогательными библиотеками весит больше 50 МБ (например, из-за больше веса библиотек в node_modules). Но есть нюансы: (1) нужно самому описать webpack конфиг и подготовить код для лямбды (это можно сделать с помощью плагина: serverless-webpack) и (2) зависимости с bin-исходниками вебпак может не собрать и после деплоя функции в облаке код может не запускаться.
В данном материале рассмотрим 3-ий вариант - использование плагина, который избавит от ручной сборки и подготовит все за нас.
Если вы хотите посмотреть код, полученный в результате, можете сразу открывать github: aws-lambda-typescript-weather-app.
Важно: Для деплоя функции у вас должен быть аккаунт в AWS, добавлены переменные окружения AWS_ACCESS_KEY_ID
и AWS_SECRET_ACCESS_KEY
:
~/.aws/credentials
:[default]
aws_access_key_id = <ACCESS_KEY_ID>
aws_secret_access_key = <SECRET_ACCESS_KEY>
Первым делом установим зависимости и настроим два конфигурационных файла (для TS, и для и Serverless). Создадим директорию для проекта, создадим package.json
(npm init -f) и установим зависимости:
mkdir weather-app
cd weather-app
npm init -f
npm i --save-dev @types/node @types/aws-lambda @types/axios @types/jest typescript serverless serverless-offline serverless-plugin-typescript serverless-dotenv-plugin jest ts-jest
npm i --save axios
@types/*
- TypeScript типы, необходимые для работы кодаtypescript
- TypeScript компиляторserverless
- фреймворк для работы с лямбдами и плагины для него (плагин serverless-offline
позволит запускать лямбду локально, serverless-plugin-typescript
- использовать TypeScript, serverless-dotenv-plugin
- читать переменные окружения из .env
файла);jest
, ts-jest
- для тестовaxios
- библиотека для http-запросовЧтобы сохранить размер загружаемых данных в AWS небольшим, важно добавлять зависимости в devDependencies
.
Добавим в секцию scripts
скрипты для запуска лямбды локально, для деплоя и для прогона тестов:
{
"local": "sls offline start",
"deploy": "sls deploy",
"test": "jest"
}
Данные о погоде будем брать из API Weatherstack, для работы которого нужен API_KEY
(для его получения достаточно зарегистрироваться). Ключ нужно положить в .env
файл. Содержимое этого файла выглядит следующим образом:
WEATHERSTACK_API_KEY=<API_KEY>
Так же нужно добавить в package.json
опцию jest, в которой указать, что все тесты с расширением .ts
или .tsx
прогонять через ts-jest
(по умолчанию jest не умеет работать с TypeScript):
{
"jest": {
"transform": {
".+\\.tsx?$": "ts-jest"
}
}
}
Далее настроим конфиги.
Все доступные параметры конфигурации описаны в официальной документации.
Файл serverless.yml
:
service: aws-lambda-typescript-weather-app
plugins:
- serverless-plugin-typescript
- serverless-dotenv-plugin
- serverless-offline
provider:
name: aws
runtime: nodejs12.x
stage: dev
region: us-east-2
environment:
WEATHERSTACK_API_KEY: ${env:WEATHERSTACK_API_KEY}
functions:
getWeather:
handler: src/getWeather.hander
events:
- http:
path: /weather/{city}/current
method: get
Из интересного:
getWeather
, которая будет триггериться с помощью HTTP-события (в рамках AWS - это API Gateway);serverless-plugin-typescript
перед serverless-offline
, чтобы сначала код скомпилировался, а потом уже запускался локально.Файл tsconfig.json
:
{
"compilerOptions": {
"strictNullChecks": true,
"noImplicitAny": true,
"outDir": ".build",
"rootDir": "./",
"module": "commonjs",
"lib": ["es2019", "es2020.bigint", "es2020.string", "es2020.symbol.wellknown"],
"target": "es2019"
}
}
По традиции рассмотрим интересное:
strictNullChecks
, noImplicitAny
- не обязательные правила, но которые включают более строгие проверки TypeScript и позволяют писать более затипизированный код;es2019
(подробнее можно почитать здесь: https://stackoverflow.com/questions/59787574/typescript-tsconfig-settings-for-node-js-12). target
сообщает компилятору, какую версию библиотеки включать при компиляции, поэтому в конфиге указан с соответствующим значением."lib": ["es5", "es2015.promise"]
и TS не будет дополнительно транспилировать промисы.Стоит иметь ввиду, что TypeScript не подключает полифилы и о их подключнии нужно позаботиться самому.
Создадим файл scr/types.ts
в котором опишем необходимые типы для работы приложения. В пакете @types/aws-lambda
уже есть необходимые типы для работы с лямбда-функциями, поэтому с нуля писать их не нужно. Например, вот так выглядит тип ApiGatewayProxyEventBase
:
Для начала опишем типы для входного события лямбда-функции и возвращаемый результат:
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
export type HttpEventRequest<T = null> = Omit<APIGatewayProxyEvent, 'pathParameters'> & {
pathParameters: T
}
export type HttpResponse = Promise<APIGatewayProxyResult>;
Если с типом HttpEventResponse
все должно быть понятно - так как будем использовать async-функцию, то ожидаем в качестве ответа Promise, который вернет уже готовый тип APIGatewayProxyResult
.
А что касается HttpEventRequest
, могут возникнуть вопросы. Сейчас рассмотрим проблему и приведенный выше способ решения. В базовом типе APIGatewayProxyEvent
свойство pathParameters
описано следующим образом:
pathParameters: { [name: string]: string } | null;
И если в коде попытаться получить из pathParameters параметр пути в url (в нашем случае это weather/{city}/current
и параметр city
), то TypeScript будет выдавать ошибку:
Это связано с тем, что тип pathParameters
может быть null, который нельзя деструктизовать. Для решения проблемы есть два варианта:
const { city } = event.pathParameters!;
Omit
, который удаляет из типа переданный ключ, удалить из типа APIGatewayProxyEvent
свойство pathParameters
и добавить его отдельным типом с использованием дженерика. Такой тип можно использовать вот так:const event: HttpEventRequest = {...};
Если не ожидается использование pathParameters
(параметр будет равен null) или вот так:
const event: HttpEventRequest<{ city: string }> = {...};
В данном случае ожидается обязательный параметр city
(который мы явно передали) в pathParameters
. Если попробовать взять другое свойство, то TypeScript ожидаемо подсветит эту строчку:
Я выбрал второй способ, который запретит брать из параметров неожидаемые значения.
Опишем еще несколько типов:
// Тип body, возвращаемый пользователю из лямбды
export type HttpResponseBody = {
city: string;
temperature: number;
textWeather: string[];
}
// Тип успешного ответа API Weatherstack
export type WeatherstackSuccessResponse = {
request: {
type: string;
query: string;
language: string;
unit: string;
};
location: {
name: string;
country: string;
region: string;
lat: string;
lon: string;
};
current: {
temperature: number;
weather_descriptions: string[];
wind_speed: number;
pressure: number;
};
};
// Тип ответа с ошибкой API Weatherstack
export type WeatherstackErrorResponse = {
success: false;
error: object;
}
// Тип ответа API Weatherstack
export type WeatherstackResponse = WeatherstackSuccessResponse | WeatherstackErrorResponse;
Рекомендую почитать материал "Как писать тесты в Nodejs" о правильных практиках и подходах по написанию тестов.
Давайте пойдем по методологии TDD и опишем два тест-кейса для лямбды, после приступим к реализации. В данном примере достаточно проверить два кейса: когда API Weatherstack возвращает информацию о погоде и когда возвращает ошибку.
Для начала нам нужны стабы (заглушки) ответа от API Weatherstack (успешный и неудачный), а так же объект event, который принимает лямбда-функция (можете взять из репозитория).
Перед описанием тест-кейсов создадим перемененную с дефолтным event и хук beforeEach
, в котором перед каждым тестом будем отчищать моки, установленные jest-ом.
const defaultEvent = {
// стаб объекта event, сформированный api gatetway
...httpEventMock,
pathParameters: { city: 'london' },
} as any;
beforeEach(() => {
jest.clearAllMocks();
});
describe('getWeather handler', () => {
// ...
});
И опишем два тест-кейса:
it('should respond current weather by city', async () => {
const requestSpy = jest
.spyOn(axios, 'get')
.mockImplementation(async () => ({ data: weatherstackSuccessResponse }));
const actual = await handler(defaultEvent);
const expected = respondJson({
city: 'Lakefront Airport',
temperature: 22,
textWeather: ['Clear']
}, 200);
expect(actual).toEqual(expected);
expect(requestSpy).toHaveBeenCalled();
})
it('should respond error if weatherstack API respond error', async () => {
const requestSpy = jest
.spyOn(axios, 'get')
.mockImplementation(async () => ({ data: weatherstackErrorResponse }));
const actual = await handler(defaultEvent);
const expected = respondJson({ error: true }, 200);
expect(actual).toEqual(expected);
expect(requestSpy).toHaveBeenCalled();
});
С помощью jest.spyOn
замокаем http-запрос до API. Далее вызываем функцию, передавая defaultEvent
в качестве первого аргумента. А с помощью хелпера respondJson
формируем ответ лямбды. Также стоит проверить, что spy-агент был вызван.
Теперь запустим тесты npm test
:
Они ожидаемо упали, можно приступать к реализации.
Напишем код хелпера для формирования ответа и лямбду:
export function respondJson(body: object, statusCode: number) {
return {
statusCode,
body: JSON.stringify(body)
};
}
export async function handler(event: HttpEventRequest<{ city: string }>): HttpResponse {
const { city } = event.pathParameters;
return respondJson({ city }, 200);
}
Эту функцию можно запустить локально. После запуска будет создана директория .build
, в которой можно посмотреть скомпилированный в JavaScript код:
Допишем отправку запроса в API, обработку ответа от API и формирования ответа лямбды.
const API_KEY = process.env.WEATHERSTACK_API_KEY;
export async function handler(event: HttpEventRequest<{ city: string }>): HttpResponse {
const { city } = event.pathParameters;
// Делаем запрос в API Weatherstack
const endpoint = 'http://api.weatherstack.com/current';
const { data } = await axios.get<WeatherstackResponse>(endpoint, {
params: { access_key: API_KEY, query: city }
});
// Если есть ошибка, возвращаем это пользователю
// Оператор in помогает TypeScript работать с union-типами
if ('error' in data) {
return respondJson({ error: true }, 200);
}
// Формируем ответ
const response: HttpResponseBody = {
city: data.location.name,
temperature: data.current.temperature,
textWeather: data.current.weather_descriptions,
}
return respondJson(response, 200);
}
Теперь можно еще раз запустить функцию локально и сделать запросы в браузерной строке для возврата погоды:
http://localhost:3000/dev/weather/{city}/current
И запустить тесты, чтобы убедиться в их успешном прохождении:
Командой npm run deploy
можно задеплоить функцию в AWS. В терминале вы будете видеть весь процесс деплоя лямбды (все файлы складываются в zip архив и заливаются в S3). В итоге вы получите постоянный эндпоинт, что-то типа: https://xxx.execute-api.us-east-2.amazonaws.com/dev/.
Делая запрос на GET https://xxx.execute-api.us-east-2.amazonaws.com/dev/weather/{city}/current
вы получите информацию о погоде:
Писать лямбды на TypeScript довольно просто, достаточно добавить конфигурационный файл tsconfig.json
и использовать плагин serverless-plugin-typescript
.
Написанная нами лямбда не готова для продакшена: нужно предусмотреть валидацию входных данных, обработку ошибок и другие вещи, присущие всем API Endpoint-ам, но старт работы с TypeScript положен, проект подготовлен. Дальше - только ваши бизнес требования и фантазия.