В последние месяцы я провел некоторое время с API Gateway, чтобы создать серверную часть пользовательского интерфейса. В этом посте я расскажу об опыте работы с API Gateway и о том, как создать простую систему CRUD, защищенную с помощью пользовательского авторизатора запросов Lambda.

Описание изображения

Как вы можете видеть на изображении выше, серверная часть состоит из записи A Route53, которая указывает на мой личный домен.

    RecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref Route53HostedZoneId
      Name: !Ref Route53DomainName
      Type: A
      AliasTarget:
        HostedZoneId: !Ref CustomDomainHostedZoneId
        DNSName: !Ref CustomDomainName
Войти в полноэкранный режим

Выйти из полноэкранного режима

Посмотреть полный файл на GitHub

Пользовательские доменные имена — это более простые и интуитивно понятные URL-адреса, которые я могу предоставить пользователям моего API или, в моем случае, пользователю внешнего интерфейса вместо случайного идентификатора API. Основное преимущество заключается в том, что я могу отделить создание пользовательского домена от создания шлюза API, что позволяет мне избежать изменения пользовательского интерфейса, если мне по какой-либо причине нужно удалить стек API.

Пользовательский домен сопоставляется с моим API с помощью сопоставления базового пути. Базовый путь должен быть указан как часть URL-адреса после имени домена, а значение должно быть уникальным для всех сопоставлений в рамках одного API. Я мог получить доступ к нескольким API за одним настраиваемым доменом, добавив базовое имя пути вместо развертывания различных настраиваемых доменов или ссылки на несколько случайных идентификаторов API.

  CustomDomainName:
    Type: AWS::ApiGateway::DomainName
    Properties:
      RegionalCertificateArn: !Sub "arn:aws:acm:${AWS::Region}:${AWS::AccountId}:certificate/${CertId}"
      DomainName: !Ref DomainName
      EndpointConfiguration:
        Types:
          - REGIONAL
      Tags:
        - Key: Name
          Value: !Ref AWS::StackName
        - Key: env
          Value: !Ref StageName

  MyApi1Mapping:
    Type: AWS::ApiGateway::BasePathMapping
    DependsOn: CustomDomainName
    Properties:
      RestApiId: !Ref MyApi1
      DomainName: !Ref CustomDomainName
      BasePath: 'api1'
      Stage: !Ref StageName

  MyApi2Mapping:
    Type: AWS::ApiGateway::BasePathMapping
    DependsOn: CustomDomainName
    Properties:
      RestApiId: !Ref MyApi2
      DomainName: !Ref CustomDomainName
      BasePath: 'api2'
      Stage: !Ref StageName
Войти в полноэкранный режим

Выйти из полноэкранного режима

Посмотреть полный файл на GitHub

Шлюз API будет иметь основные ресурсы:

Описание изображения

Каждый ресурс интегрируется с одной функцией Lambda Function. Внутри каждой функции Lambda некоторая логика и работа с DynamoDB в качестве хранилища. Помните, что единая функциональность Lambda следует принципу KISS и делает функцию Lambda простой в обслуживании и понимании, не говоря уже о других преимуществах, таких как возможность разного масштабирования, разные параметры конфигурации и гораздо более строгие правила безопасности.

Все становится интереснее в Лямбда-интеграция часть. Самый простой — это интеграция с прокси, где я задаю HTTP-метод интеграции, URI конечной точки интеграции для ARN действия вызова функции Lambda конкретной функции Lambda и предоставляю шлюзу API разрешение на вызов функции Lambda от вашего имени. Когда клиент отправляет запрос API, шлюз API передает необработанный запрос встроенной функции Lambda как есть.

В лямбда интеграция без прокси, я должен указать, как данные входящего запроса сопоставляются с запросом на интеграцию и как результирующие данные ответа интеграции сопоставляются с ответом метода. Это означает, что ничего не будет работать из коробки, потому что, если, например, входной запрос Lambda использует какой-то SDK API Type, это будет несовместимо с входным запросом. Кроме того, если код функции Lambda возвращает другой код состояния, они больше не работают. Так что все равно 200, если только я не поиграюсь с регулярными строковыми выражениями.

Перед входом в Лямбду интеграция без прокси конфигурации, я хотел защитить доступ к моему API, создав детальную авторизацию. Из-за этого мне нужно использовать Пользовательский авторизатор шлюза API.
Существует два типа пользовательских авторизаторов: ТОКЕН а также ЗАПРОСи главное отличие, если объект события которую функция Lambda получает. я использовал ЗАПРОС потому что он содержит всю необходимую информацию, например заголовки, строку запроса и другую информацию.

Авторизатор лямбда-запросов имеет мою пользовательскую логику для проверки JWT и оттуда получения утверждений и проверки, разрешено ли пользователю выполнять определенное действие или нет.

Код (Rust) может выглядеть так:

use aws_lambda_events::apigw::{
    ApiGatewayCustomAuthorizerRequestTypeRequest, ApiGatewayCustomAuthorizerResponse, ApiGatewayCustomAuthorizerPolicy, IamPolicyStatement,
};
use lambda_runtime::{run, service_fn, Error, LambdaEvent};
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing_subscriber::fmt()
        .with_ansi(false)
        .without_time()
        .with_max_level(tracing_subscriber::filter::LevelFilter::INFO)
        .init();

    run(service_fn(function_handler)).await
}

pub async fn function_handler(event: LambdaEvent<ApiGatewayCustomAuthorizerRequestTypeRequest>) -> Result<ApiGatewayCustomAuthorizerResponse, Error> {
    // do something with the event payload
    let method_arn = event.payload.method_arn.unwrap();
    // for example we could use the authorization header
    if let Some(token) = event.payload.headers.get("authorization") {
        // do something with the token
        // my custom logic

        return Ok(custom_authorizer_response(
            "ALLOW",
            "some_principal",
            &method_arn,
        ));
    }

    Ok(custom_authorizer_response(
      &"DENY".to_string(), 
      "", 
      &method_arn))
}

pub fn custom_authorizer_response(effect: &str, principal: &str, method_arn: &str) -> ApiGatewayCustomAuthorizerResponse {
    let stmt = IamPolicyStatement {
        action: vec!["execute-api:Invoke".to_string()],
        resource: vec![method_arn.to_owned()],
        effect: Some(effect.to_owned()),
    };
    let policy = ApiGatewayCustomAuthorizerPolicy {
        version: Some("2012-10-17".to_string()),
        statement: vec![stmt],
    };
    ApiGatewayCustomAuthorizerResponse {
        principal_id: Some(principal.to_owned()),
        policy_document: policy,
        context: json!({ "email": principal }), // 
        usage_identifier_key: None,
    }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

За исключением пользовательской логики, авторизатор запросов Lambda будет Отрицать или же Разрешать запрос.

Шлюз API позволяет мне кэшировать ответ для авторизатора.
Авторизатор обычно настроен на использование заголовка авторизации в качестве источника идентификации (например, заголовок «Авторизация» со значением 123). Затем эта политика кэшируется и используется для всех других запросов с тем же источником идентификации. Я мог бы отключить кеш, но у него есть недостатки, и один из них — дополнительная задержка при многократном вызове авторизатора для одного и того же запроса.

Это означает, что, поскольку заголовок авторизации один и тот же, запросы, сделанные к разным конечным точкам или методам, приведут к одному и тому же кэшированному ответу авторизатора. Это не работает, если у пользователя есть отдельное разрешение на основе работы API.

Чтобы решить эту проблему, не отключая кеш, я могу настроить переменные контекста и заголовок авторизации. В частности, я могу использовать переменные \»$context.httpMethod\» и \»$context.resourceId\», которые описывают метод и конкретный ресурс, к которому был сделан запрос. Это означает, что даже если заголовок авторизации один и тот же, запросы к разным конечным точкам приведут к тому, что шлюз API вызовет авторизатор Lambda для создания нового ответа на эти запросы. При таком подходе я бы затем разрешил модульный доступ к методам, потому что политику ресурсов можно настроить так, чтобы эти переменные предоставляли доступ только к этому ресурсу. Я также могу реализовать логику для предоставления большего доступа на основе метода и ресурса запроса.

  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: !Sub ${AWS::StackName}
      StageName: !Ref StageName
      ...
      Auth:
        AddDefaultAuthorizerToCorsPreflight: false
        Authorizers:
          jwt:
            FunctionArn: !Ref JwtArn
            FunctionPayloadType: REQUEST
            Identity:
              Context:
                - httpMethod
                - resourceId
              Headers:
                - Authorization
            JwtConfiguration:
              issuer: ....
              audience: .....
        DefaultAuthorizer: jwt
        ResourcePolicy:
          CustomStatements: [{
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "execute-api:/*/*/*"
          }]
      Tags:
        Name: myapi
        Env: !Ref StageName
Войти в полноэкранный режим

Выйти из полноэкранного режима

Посмотреть полный файл на GitHub

Поскольку задействован кеш, не существует универсального способа, поэтому как долго я должен кэшировать ответ авторизатора шлюза API? Это зависит от типа приложения, например, внутреннее или общедоступное приложение, трафика и многих других требований. Невозможно аннулировать один пользовательский ответ авторизатора, поэтому я должен жить с некоторыми устаревшими данными или установить время кэширования очень низкого уровня. В случае массивной бреши есть способ смыть все с помощью флеш-этап-авторизаторы-кеш

Первоначально, когда я создавал серверную часть пользовательского интерфейса, я использовал только интеграцию прокси-сервера Lambda, и без каких-либо усилий, через несколько часов, все было готово и запущено. Во время тестирования и прототипирования я задался вопросом, как мне сохранить пользователя, который выполнил операцию, в базе данных.

При использовании пользовательского авторизатора есть способ внедрить дополнительный контекст в запрос и передать объект контекста из авторизатора Lambda непосредственно во внутреннюю функцию Lambda как часть входного события.

context: json!({ "email": principal }), // 
Войти в полноэкранный режим

Выйти из полноэкранного режима

С моей стороны было наивно думать, что это поддерживаемая функция, потому что оказалось, что это работает только для:

При использовании API Gateway в типе HTTP API отсутствуют многие другие важные функции, поэтому многие люди всегда указывают на тип REST API по какой-то причине. К сожалению, в последние годы REST API не получил должного внимания со стороны AWS, и его функциональные возможности не сильно улучшились. Например, я хочу передать объект контекста из авторизатора Lambda непосредственно во внутреннюю функцию Lambda как часть входного события. В таком случае мне нужно выйти из зоны комфорта интеграции прокси и уйти в дикий мир интеграция без прокси.

В интеграции Lambda без прокси я должен указать, как данные входящего запроса сопоставляются с запросом на интеграцию и как результирующие данные ответа интеграции сопоставляются с ответом метода, и здесь я встретил своего нового утомительного и подверженного ошибкам лучшего друга. Язык шаблонов скорости также известный как ВТЛ. В VTL не было бы ничего плохого, если бы опыт работы с AWS был лучше, например, с помощью какого-нибудь инструмента автозаполнения или способа быстрой проверки кода, что-то вроде MappingTool

Хотя вы можете найти множество примеров развертывания шлюза API на Бессерверная Земля, это только часть полной истории. Не хватает документации или полных примеров, и поэтому Эрик Джонсон сказал:

Описание изображения

Я последовал его совету и создал полный пример CRUD после того, как отказался делать это в чистом CloudFormation или других инструментах.

Прежде чем показать вам весь код, я хочу знать о нескольких вещах, потому что экспорт не работает из коробки:

  • я должен очистить его
  • Мне нужно было добавить Fn::Sub: все вокруг
  • Мне нужно было удалить политику x-amazon-apigateway, потому что она генерировала круговую зависимость
  • Мне нужно было удалить информационный раздел

Это мелочи, но я хотел получить лучшую документацию/примеры, поэтому эта статья и есть.

Полностью CRUD-шаблон для шлюза API AWS со спецификациями OpenAPI, которые соответствуют следующей архитектуре:

Описание изображения

В результате получится два файла:

Шаблон стандартный. Я создаю AWS::Serverless::Api, указывающую на файл спецификации OpenAPI:

      DefinitionBody: # an OpenApi definition
        'Fn::Transform':
          Name: 'AWS::Include'
          Parameters:
            Location: './openapi.yaml'
      OpenApiVersion: 3.0.3
Войти в полноэкранный режим

Выйти из полноэкранного режима

И разверните каждую функцию Lambda для каждого ресурса моего API.

Хотя приведенный выше шаблон настроен на использование Rust

Globals:
  Function:
    MemorySize: 256
    Architectures: ["arm64"]
    Handler: bootstrap
    Runtime: provided.al2
    Timeout: 29
    Environment:
      Variables:
        RUST_BACKTRACE: 1
        RUST_LOG: info
Войти в полноэкранный режим

Выйти из полноэкранного режима

Файл спецификации OpenAPI является агностическим и может использоваться для любой среды выполнения:

Захватывающая часть шаблона Файл спецификации OpenAPI — это интеграция x-amazon-apigateway для POST и PATCH.

я использовал интеграция без прокси для этих двух ресурсов, поэтому я должен сопоставить свой запрос и мои ответы.

Запрос на интеграцию является:

#set($allParams = $input.params())
{
"body" : $input.json('$'),
"params" : {
#foreach($type in $allParams.keySet())
    #set($params = $allParams.get($type))
"$type" : {
    #foreach($paramName in $params.keySet())
    "$paramName" : "$util.escapeJavaScript($params.get($paramName))"
        #if($foreach.hasNext),#end
    #end
}
    #if($foreach.hasNext),#end
#end
},
"stage-variables" : {
#foreach($key in $stageVariables.keySet())
"$key" : "$util.escapeJavaScript($stageVariables.get($key))"
    #if($foreach.hasNext),#end
#end
},
"auth" : {
    "email" : "$context.authorizer.email"
    }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Запрос ввода Lambda выглядит следующим образом:

{
      "auth":{
         "email":"a@a.com"
      },
      "body":{
         "key":"my_id",
         "property1":"something"
      },
      "params":{
         "header":{
            "Accept":"*/*",
            "Accept-Encoding":"gzip, deflate, br",
            "Authorization":"Bearer xxxxx",
            "Content-Type":"application/json",
            "Host":"something.com",
            "User-Agent":"PostmanRuntime/7.29.2",
            "X-Amzn-Trace-Id":"Root=1-4343434-15fc477270bcvce21c1bcc983d",
            "X-Forwarded-For":"X.X.X.X",
            "X-Forwarded-Port":"443",
            "X-Forwarded-Proto":"https",
            "X-Restrict-Header":"ca2sdfsd0-34ds-407c-b379-7fg3re0efb5"
         },
         "path":{

         },
         "querystring":{

         }
      },
      "stage-variables":{

      }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Ответ интеграции должны следовать лямбда-логике.

Здесь вам нужно отобразить весь код состояния, который возвращает Lambda, например, 200, 404 и так далее.

Описание изображения

По умолчанию основной ответ имеет код состояния 200. Однако я должен использовать регулярное выражение лямбда-ошибки, если я хочу вернуться, как мой шаблон выше 409 или 500. Это означает, что мне нужно выдать ошибку со строкой, содержащей некоторый КЛЮЧ, который я можно использовать с регулярным выражением Lambda Error на основе строки errorMessage ошибки Lambda.

Внутри моего лямбда-кода я выдаю ошибку, которая начинается с определенной строки:

  • «Ошибка409: что-то случилось»
  • «Ошибка500: что-то взорвалось»

Теперь с некоторыми заменами VTL я перехватываю строку и создаю собственный ответ для пользовательского интерфейса.

#set( $response = $input.path('$.errorMessage').replace("Error409: ", ""))
#set( $errors = $response.replace('"', ""))
{ 
"errors": 
  [
#foreach($item in $errors.split('##'))
   "$item"
 #if($foreach.hasNext),#end
#end
  ]
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Описание изображения


Вывод

Цель этой статьи — создать небольшое руководство, охватывающее некоторые распространенные сценарии (CRUD) и собрать их все в одном месте вместо примера «Hello World», который потребовал бы поиска скудной информации по всему Интернету.

Этот пример неполный и, безусловно, не покроет всех потребностей, таких как регулирование или ключи API, но его расширение с некоторыми конкретными потребностями должно быть простым.

Я рассмотрел следующую тему:

  • Разверните собственный домен перед API, чтобы отделить клиент (в моем случае пользовательский интерфейс) и API.
  • Полный пример API CRUD с использованием спецификации OpenAPI
  • Как использовать авторизатор запросов Lambda, который вводит дополнительный контекст в запрос и передает объект контекста из авторизатора Lambda непосредственно во внутреннюю функцию Lambda как часть ввода
  • Смешайте один прокси-сервер интеграции API Lambda и прокси-сервер без интеграции
  • Некоторые VTL для преобразования входного запроса и лямбда-ответа с несколькими кодами состояния.

Я надеюсь, вам понравится. По любым вопросам вы можете найти меня на Твиттер