From 12e63801c66fedf91a1c0c8c59e60697d49d41e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20L=C3=A9pine?= Date: Thu, 27 Jun 2019 08:35:34 +0200 Subject: [PATCH] feat(aws-apigateway): expand RestApi support to models, parameters and validators (#2960) Fixes #905: "apigateway: "methodResponses" is missing from MethodOptions" Fixes #1695: apigateway: missing support for models Fixes #727: API Gateway: improve API for request parameters and responses Fixes #723: API Gateway: missing features Fixes #2957: RestApi to use logical id as a name for APIs instead of name of current construct Adds support for JsonSchema in Model Aligns Model to the PhysicalName convention. No breaking change, documentation updated --- packages/@aws-cdk/aws-apigateway/README.md | 144 ++++++++++++++++- packages/@aws-cdk/aws-apigateway/lib/index.ts | 2 + .../aws-apigateway/lib/json-schema.ts | 76 +++++++++ .../@aws-cdk/aws-apigateway/lib/method.ts | 34 +++- packages/@aws-cdk/aws-apigateway/lib/model.ts | 147 +++++++++++++++++- .../aws-apigateway/lib/requestvalidator.ts | 88 +++++++++++ .../@aws-cdk/aws-apigateway/lib/restapi.ts | 24 ++- packages/@aws-cdk/aws-apigateway/lib/util.ts | 55 +++++++ packages/@aws-cdk/aws-apigateway/package.json | 4 +- .../aws-apigateway/test/test.method.ts | 140 ++++++++++++++++- .../aws-apigateway/test/test.model.ts | 75 +++++++++ .../test/test.requestvalidator.ts | 60 +++++++ .../aws-apigateway/test/test.restapi.ts | 118 ++++++++++---- 13 files changed, 926 insertions(+), 41 deletions(-) create mode 100644 packages/@aws-cdk/aws-apigateway/lib/json-schema.ts create mode 100644 packages/@aws-cdk/aws-apigateway/lib/requestvalidator.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/test.model.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/test.requestvalidator.ts diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index f6c96bdeeacaa..c949362c5039d 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -155,6 +155,147 @@ plan.addApiStage({ }); ``` +### Working with models + +When you work with Lambda integrations that are not Proxy integrations, you +have to define your models and mappings for the request, response, and integration. + +```ts +const hello = new lambda.Function(this, 'hello', { + runtime: lambda.Runtime.Nodejs10x, + handler: 'hello.handler', + code: lambda.Code.asset('lambda') +}); + +const api = new apigateway.RestApi(this, 'hello-api', { }); +const resource = api.root.addResource('v1'); +``` + +You can define more parameters on the integration to tune the behavior of API Gateway + +```ts +const integration = new LambdaIntegration(hello, { + proxy: false, + requestParameters: { + // You can define mapping parameters from your method to your integration + // - Destination parameters (the key) are the integration parameters (used in mappings) + // - Source parameters (the value) are the source request parameters or expressions + // @see: https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html + "integration.request.querystring.who": "method.request.querystring.who" + }, + allowTestInvoke: true, + requestTemplates: { + // You can define a mapping that will build a payload for your integration, based + // on the integration parameters that you have specified + // Check: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + "application/json": '{ "action": "sayHello", "pollId": "$util.escapeJavaScript($input.params(\'who\'))" }' + }, + // This parameter defines the behavior of the engine is no suitable response template is found + passthroughBehavior: PassthroughBehavior.Never, + integrationResponses: [ + { + // Successful response from the Lambda function, no filter defined + // - the selectionPattern filter only tests the error message + // We will set the response status code to 200 + statusCode: "200", + responseTemplates: { + // This template takes the "message" result from the Lambda function, adn embeds it in a JSON response + // Check https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + "application/json": '{ "state": "ok", "greeting": "$util.escapeJavaScript($input.body)" }' + }, + responseParameters: { + // We can map response parameters + // - Destination parameters (the key) are the response parameters (used in mappings) + // - Source parameters (the value) are the integration response parameters or expressions + 'method.response.header.Content-Type': "'application/json'", + 'method.response.header.Access-Control-Allow-Origin': "'*'", + 'method.response.header.Access-Control-Allow-Credentials': "'true'" + } + }, + { + // For errors, we check if the error message is not empty, get the error data + selectionPattern: '(\n|.)+', + // We will set the response status code to 200 + statusCode: "400", + responseTemplates: { + "application/json": '{ "state": "error", "message": "$util.escapeJavaScript($input.path(\'$.errorMessage\'))" }' + }, + responseParameters: { + 'method.response.header.Content-Type': "'application/json'", + 'method.response.header.Access-Control-Allow-Origin': "'*'", + 'method.response.header.Access-Control-Allow-Credentials': "'true'" + } + } + ] +}); + +``` + +You can define validation models for your responses (and requests) + +```ts +// We define the JSON Schema for the transformed valid response +const responseModel = api.addModel('ResponseModel', { + contentType: "application/json", + modelName: 'ResponseModel', + schema: { "$schema": "http://json-schema.org/draft-04/schema#", "title": "pollResponse", "type": "object", "properties": { "state": { "type": "string" }, "greeting": { "type": "string" } } } +}); + +// We define the JSON Schema for the transformed error response +const errorResponseModel = api.addModel('ErrorResponseModel', { + contentType: "application/json", + modelName: 'ErrorResponseModel', + schema: { "$schema": "http://json-schema.org/draft-04/schema#", "title": "errorResponse", "type": "object", "properties": { "state": { "type": "string" }, "message": { "type": "string" } } } +}); + +``` + +And reference all on your method definition. + +```ts +// If you want to define parameter mappings for the request, you need a validator +const validator = api.addRequestValidator('DefaultValidator', { + validateRequestBody: false, + validateRequestParameters: true +}); +resource.addMethod('GET', integration, { + // We can mark the parameters as required + requestParameters: { + "method.request.querystring.who": true + }, + // We need to set the validator for ensuring they are passed + requestValidator: validator, + methodResponses: [ + { + // Successful response from the integration + statusCode: "200", + // Define what parameters are allowed or not + responseParameters: { + 'method.response.header.Content-Type': true, + 'method.response.header.Access-Control-Allow-Origin': true, + 'method.response.header.Access-Control-Allow-Credentials': true + }, + // Validate the schema on the response + responseModels: { + "application/json": responseModel + } + }, + { + // Same thing for the error responses + statusCode: "400", + responseParameters: { + 'method.response.header.Content-Type': true, + 'method.response.header.Access-Control-Allow-Origin': true, + 'method.response.header.Access-Control-Allow-Credentials': true + }, + responseModels: { + "application/json": errorResponseModel + } + } + ] +}); +``` + #### Default Integration and Method Options The `defaultIntegration` and `defaultMethodOptions` properties can be used to @@ -259,12 +400,9 @@ to allow users revert the stage to an old deployment manually. ### Missing Features -See [awslabs/aws-cdk#723](https://github.com/awslabs/aws-cdk/issues/723) for a -list of missing features. ### Roadmap -- [ ] Support defining REST API Models [#1695](https://github.com/awslabs/aws-cdk/issues/1695) ---- diff --git a/packages/@aws-cdk/aws-apigateway/lib/index.ts b/packages/@aws-cdk/aws-apigateway/lib/index.ts index cb8e7ffde866f..781cf9c370e7f 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/index.ts @@ -11,7 +11,9 @@ export * from './usage-plan'; export * from './vpc-link'; export * from './methodresponse'; export * from './model'; +export * from './requestvalidator'; export * from './authorizer'; +export * from './json-schema'; // AWS::ApiGateway CloudFormation Resources: export * from './apigateway.generated'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/json-schema.ts b/packages/@aws-cdk/aws-apigateway/lib/json-schema.ts new file mode 100644 index 0000000000000..9749269fb2302 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/json-schema.ts @@ -0,0 +1,76 @@ +export enum JsonSchemaVersion { + /** + * In API Gateway models are defined using the JSON schema draft 4. + * @see https://tools.ietf.org/html/draft-zyp-json-schema-04 + */ + DRAFT4 = 'http://json-schema.org/draft-04/schema#', + DRAFT7 = 'http://json-schema.org/draft-07/schema#' +} + +export enum JsonSchemaType { + NULL = "null", + BOOLEAN = "boolean", + OBJECT = "object", + ARRAY = "array", + NUMBER = "number", + INTEGER = "integer", + STRING = "string" +} + +/** + * Represents a JSON schema definition of the structure of a + * REST API model. Copied from npm module jsonschema. + * + * @see http://json-schema.org/ + * @see https://github.com/tdegrunt/jsonschema + */ +export interface JsonSchema { + // Special keywords + readonly schema?: JsonSchemaVersion; + readonly id?: string; + readonly ref?: string; + + // Common properties + readonly type?: JsonSchemaType | JsonSchemaType[]; + readonly title?: string; + readonly description?: string; + readonly 'enum'?: any[]; + readonly format?: string; + readonly definitions?: { [name: string]: JsonSchema }; + + // Number or Integer + readonly multipleOf?: number; + readonly maximum?: number; + readonly exclusiveMaximum?: boolean; + readonly minimum?: number; + readonly exclusiveMinimum?: boolean; + + // String + readonly maxLength?: number; + readonly minLength?: number; + readonly pattern?: string; + + // Array + readonly items?: JsonSchema | JsonSchema[]; + readonly additionalItems?: JsonSchema[]; + readonly maxItems?: number; + readonly minItems?: number; + readonly uniqueItems?: boolean; + readonly contains?: JsonSchema | JsonSchema[]; + + // Object + readonly maxProperties?: number; + readonly minProperties?: number; + readonly required?: string[]; + readonly properties?: { [name: string]: JsonSchema }; + readonly additionalProperties?: JsonSchema; + readonly patternProperties?: { [name: string]: JsonSchema }; + readonly dependencies?: { [name: string]: JsonSchema | string[] }; + readonly propertyNames?: JsonSchema; + + // Conditional + readonly allOf?: JsonSchema[]; + readonly anyOf?: JsonSchema[]; + readonly oneOf?: JsonSchema[]; + readonly not?: JsonSchema; +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/method.ts b/packages/@aws-cdk/aws-apigateway/lib/method.ts index 7ff89de9905b0..f4c1125772e0b 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/method.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/method.ts @@ -4,6 +4,8 @@ import { IAuthorizer } from './authorizer'; import { ConnectionType, Integration } from './integration'; import { MockIntegration } from './integrations/mock'; import { MethodResponse } from './methodresponse'; +import { IModel } from './model'; +import { IRequestValidator } from './requestvalidator'; import { IResource } from './resource'; import { RestApi } from './restapi'; import { validateHttpMethod } from './util'; @@ -54,9 +56,17 @@ export interface MethodOptions { */ readonly requestParameters?: { [param: string]: boolean }; - // TODO: - // - RequestValidatorId - // - RequestModels + /** + * The resources that are used for the response's content type. Specify request + * models as key-value pairs (string-to-string mapping), with a content type + * as the key and a Model resource name as the value + */ + readonly requestModels?: { [param: string]: IModel }; + + /** + * The ID of the associated request validator. + */ + readonly requestValidator?: IRequestValidator; } export interface MethodProps { @@ -119,6 +129,8 @@ export class Method extends Resource { requestParameters: options.requestParameters, integration: this.renderIntegration(props.integration), methodResponses: this.renderMethodResponses(options.methodResponses), + requestModels: this.renderRequestModels(options.requestModels), + requestValidatorId: options.requestValidator ? options.requestValidator.requestValidatorId : undefined }; const resource = new CfnMethod(this, 'Resource', methodProps); @@ -243,6 +255,22 @@ export class Method extends Resource { return methodResponseProp; }); } + + private renderRequestModels(requestModels: { [param: string]: IModel } | undefined): { [param: string]: string } | undefined { + if (!requestModels) { + // Fall back to nothing + return undefined; + } + + const models: {[param: string]: string} = {}; + for (const contentType in requestModels) { + if (requestModels.hasOwnProperty(contentType)) { + models[contentType] = requestModels[contentType].modelId; + } + } + + return models; + } } export enum AuthorizationType { diff --git a/packages/@aws-cdk/aws-apigateway/lib/model.ts b/packages/@aws-cdk/aws-apigateway/lib/model.ts index f38de58375302..ae5188c7e800c 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/model.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/model.ts @@ -1,5 +1,16 @@ +import { Construct, Resource } from '@aws-cdk/core'; +import { CfnModel, CfnModelProps } from './apigateway.generated'; +import jsonSchema = require('./json-schema'); +import { IRestApi, RestApi } from './restapi'; +import util = require('./util'); + export interface IModel { - readonly modelId: string; + /** + * Returns the model name, such as 'myModel' + * + * @attribute + */ + readonly modelId: string; } /** @@ -18,9 +29,10 @@ export interface IModel { * } * * @see https://docs.amazonaws.cn/en_us/apigateway/latest/developerguide/models-mappings.html#models-mappings-models + * @deprecated You should use @see Model.EMPTY_MODEL */ export class EmptyModel implements IModel { - public readonly modelId = 'Empty'; + public readonly modelId = 'Empty'; } /** @@ -38,9 +50,136 @@ export class EmptyModel implements IModel { * "message" : { "type" : "string" } * } * } + * @deprecated You should use @see Model.ERROR_MODEL */ export class ErrorModel implements IModel { - public readonly modelId = 'Error'; + public readonly modelId = 'Error'; } -// TODO: Implement Model, enabling management of custom models. \ No newline at end of file +export interface ModelOptions { + /** + * The content type for the model. You can also force a + * content type in the request or response model mapping. + * + * @default - + */ + readonly contentType?: string; + + /** + * A description that identifies this model. + * @default None + */ + readonly description?: string; + + /** + * A name for the model. + * + * Important + * If you specify a name, you cannot perform updates that + * require replacement of this resource. You can perform + * updates that require no or some interruption. If you + * must replace the resource, specify a new name. + * + * @default If you don't specify a name, + * AWS CloudFormation generates a unique physical ID and + * uses that ID for the model name. For more information, + * see Name Type. + */ + readonly modelName?: string; + + /** + * The schema to use to transform data to one or more output formats. + * Specify null ({}) if you don't want to specify a schema. + */ + readonly schema: jsonSchema.JsonSchema; +} + +export interface ModelProps extends ModelOptions { + /** + * The rest API that this model is part of. + * + * The reason we need the RestApi object itself and not just the ID is because the model + * is being tracked by the top-level RestApi object for the purpose of calculating it's + * hash to determine the ID of the deployment. This allows us to automatically update + * the deployment when the model of the REST API changes. + */ + readonly restApi: IRestApi; +} + +export class Model extends Resource implements IModel { + /** + * Represents a reference to a REST API's Error model, which is available + * as part of the model collection by default. This can be used for mapping + * error JSON responses from an integration to a client, where a simple + * generic message field is sufficient to map and return an error payload. + * + * Definition + * { + * "$schema" : "http://json-schema.org/draft-04/schema#", + * "title" : "Error Schema", + * "type" : "object", + * "properties" : { + * "message" : { "type" : "string" } + * } + * } + */ + public static readonly ERROR_MODEL: IModel = new ErrorModel(); + + /** + * Represents a reference to a REST API's Empty model, which is available + * as part of the model collection by default. This can be used for mapping + * JSON responses from an integration to what is returned to a client, + * where strong typing is not required. In the absence of any defined + * model, the Empty model will be used to return the response payload + * unmapped. + * + * Definition + * { + * "$schema" : "http://json-schema.org/draft-04/schema#", + * "title" : "Empty Schema", + * "type" : "object" + * } + * + * @see https://docs.amazonaws.cn/en_us/apigateway/latest/developerguide/models-mappings.html#models-mappings-models + */ + public static readonly EMPTY_MODEL: IModel = new EmptyModel(); + + public static fromModelName(scope: Construct, id: string, modelName: string): IModel { + class Import extends Resource implements IModel { + public readonly modelId = modelName; + } + + return new Import(scope, id); + } + + /** + * Returns the model name, such as 'myModel' + * + * @attribute + */ + public readonly modelId: string; + + constructor(scope: Construct, id: string, props: ModelProps) { + super(scope, id, { + physicalName: props.modelName, + }); + + const modelProps: CfnModelProps = { + name: this.physicalName, + restApiId: props.restApi.restApiId, + contentType: props.contentType, + description: props.description, + schema: util.JsonSchemaMapper.toCfnJsonSchema(props.schema) + }; + + const resource = new CfnModel(this, 'Resource', modelProps); + + this.modelId = this.getResourceNameAttribute(resource.ref); + + const deployment = (props.restApi instanceof RestApi) ? props.restApi.latestDeployment : undefined; + if (deployment) { + deployment.node.addDependency(resource); + deployment.addToLogicalId({ model: modelProps }); + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/lib/requestvalidator.ts b/packages/@aws-cdk/aws-apigateway/lib/requestvalidator.ts new file mode 100644 index 0000000000000..783ef74fbbe1c --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/requestvalidator.ts @@ -0,0 +1,88 @@ +import { Construct, IResource, Resource } from '@aws-cdk/core'; +import { CfnRequestValidator, CfnRequestValidatorProps } from './apigateway.generated'; +import { IRestApi, RestApi } from './restapi'; + +export interface IRequestValidator extends IResource { + /** + * ID of the request validator, such as abc123 + * + * @attribute + */ + readonly requestValidatorId: string; +} + +export interface RequestValidatorOptions { + /** + * The name of this request validator. + * + * @default None + */ + readonly requestValidatorName?: string; + + /** + * Indicates whether to validate the request body according to + * the configured schema for the targeted API and method. + * + * @default false + */ + readonly validateRequestBody?: boolean; + + /** + * Indicates whether to validate request parameters. + * + * @default false + */ + readonly validateRequestParameters?: boolean; +} + +export interface RequestValidatorProps extends RequestValidatorOptions { + /** + * The rest API that this model is part of. + * + * The reason we need the RestApi object itself and not just the ID is because the model + * is being tracked by the top-level RestApi object for the purpose of calculating it's + * hash to determine the ID of the deployment. This allows us to automatically update + * the deployment when the model of the REST API changes. + */ + readonly restApi: IRestApi; +} + +export class RequestValidator extends Resource implements IRequestValidator { + public static fromRequestValidatorId(scope: Construct, id: string, requestValidatorId: string): IRequestValidator { + class Import extends Resource implements IRequestValidator { + public readonly requestValidatorId = requestValidatorId; + } + + return new Import(scope, id); + } + + /** + * ID of the request validator, such as abc123 + * + * @attribute + */ + public readonly requestValidatorId: string; + + constructor(scope: Construct, id: string, props: RequestValidatorProps) { + super(scope, id, { + physicalName: props.requestValidatorName, + }); + + const validatorProps: CfnRequestValidatorProps = { + name: this.physicalName, + restApiId: props.restApi.restApiId, + validateRequestBody: props.validateRequestBody, + validateRequestParameters: props.validateRequestParameters + }; + + const resource = new CfnRequestValidator(this, 'Resource', validatorProps); + + this.requestValidatorId = resource.ref; + + const deployment = (props.restApi instanceof RestApi) ? props.restApi.latestDeployment : undefined; + if (deployment) { + deployment.node.addDependency(resource); + deployment.addToLogicalId({ validator: validatorProps }); + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts index 17df9032fccc8..366b8186f36ed 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts @@ -5,6 +5,8 @@ import { CfnAccount, CfnRestApi } from './apigateway.generated'; import { Deployment } from './deployment'; import { Integration } from './integration'; import { Method, MethodOptions } from './method'; +import { Model, ModelOptions } from './model'; +import { RequestValidator, RequestValidatorOptions } from './requestvalidator'; import { IResource, ResourceBase, ResourceOptions } from './resource'; import { Stage, StageOptions } from './stage'; import { UsagePlan, UsagePlanProps } from './usage-plan'; @@ -212,7 +214,7 @@ export class RestApi extends Resource implements IRestApi { endpointConfiguration: props.endpointTypes ? { types: props.endpointTypes } : undefined, apiKeySourceType: props.apiKeySourceType, cloneFrom: props.cloneFrom ? props.cloneFrom.restApiId : undefined, - parameters: props.parameters, + parameters: props.parameters }); this.restApiId = resource.ref; @@ -272,6 +274,26 @@ export class RestApi extends Resource implements IRestApi { }); } + /** + * Adds a new model. + */ + public addModel(id: string, props: ModelOptions): Model { + return new Model(this, id, { + ...props, + restApi: this + }); + } + + /** + * Adds a new model. + */ + public addRequestValidator(id: string, props: RequestValidatorOptions): RequestValidator { + return new RequestValidator(this, id, { + ...props, + restApi: this + }); + } + /** * @returns The "execute-api" ARN. * @default "*" returns the execute API ARN for all methods/resources in diff --git a/packages/@aws-cdk/aws-apigateway/lib/util.ts b/packages/@aws-cdk/aws-apigateway/lib/util.ts index b42557a60a7c1..8a5e6a696eedf 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/util.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/util.ts @@ -1,4 +1,6 @@ import { format as formatUrl } from 'url'; +import jsonSchema = require('./json-schema'); + const ALLOWED_METHODS = [ 'ANY', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT' ]; export function validateHttpMethod(method: string, messagePrefix: string = '') { @@ -73,3 +75,56 @@ export function validateInteger(property: number | undefined, messagePrefix: str throw new Error(`${messagePrefix} should be an integer`); } } + +export class JsonSchemaMapper { + /** + * Transforms naming of some properties to prefix with a $, where needed + * according to the JSON schema spec + * @param schema The JsonSchema object to transform for CloudFormation output + */ + public static toCfnJsonSchema(schema: jsonSchema.JsonSchema): any { + const result = JsonSchemaMapper._toCfnJsonSchema(schema); + if (! ("$schema" in result)) { + result.$schema = jsonSchema.JsonSchemaVersion.DRAFT4; + } + return result; + } + + private static readonly SchemaPropsWithPrefix: { [key: string]: string } = { + schema: '$schema', + ref: '$ref', + id: '$id' + }; + private static readonly SubSchemaProps: { [key: string]: boolean } = { + definitions: true, + items: true, + additionalItems: true, + contains: true, + properties: true, + additionalProperties: true, + patternProperties: true, + dependencies: true, + propertyNames: true + }; + + private static _toCfnJsonSchema(schema: any): any { + if (schema === null || schema === undefined) { + return schema; + } + if ((typeof(schema) === "string") || (typeof(schema) === "boolean") || (typeof(schema) === "number")) { + return schema; + } + if (Array.isArray(schema)) { + return schema.map((entry) => JsonSchemaMapper._toCfnJsonSchema(entry)); + } + if (typeof(schema) === "object") { + return Object.assign({}, ...Object.entries(schema).map((entry) => { + const key = entry[0]; + const newKey = (key in JsonSchemaMapper.SchemaPropsWithPrefix) ? JsonSchemaMapper.SchemaPropsWithPrefix[key] : key; + const value = (key in JsonSchemaMapper.SubSchemaProps) ? JsonSchemaMapper._toCfnJsonSchema(entry[1]) : entry[1]; + return { [newKey]: value }; + })); + } + return schema; + } +} diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index c3a0268096a4e..fb8d18790463a 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -101,7 +101,9 @@ "props-physical-name:@aws-cdk/aws-apigateway.ResourceProps", "props-physical-name:@aws-cdk/aws-apigateway.UsagePlanProps", "props-physical-name-type:@aws-cdk/aws-apigateway.StageProps.stageName", - "props-physical-name:@aws-cdk/aws-apigateway.LambdaRestApiProps" + "props-physical-name:@aws-cdk/aws-apigateway.LambdaRestApiProps", + "construct-interface-extends-iconstruct:@aws-cdk/aws-apigateway.IModel", + "resource-interface-extends-resource:@aws-cdk/aws-apigateway.IModel" ] }, "stability": "experimental" diff --git a/packages/@aws-cdk/aws-apigateway/test/test.method.ts b/packages/@aws-cdk/aws-apigateway/test/test.method.ts index aa4e4f0e23120..de1575126ada0 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.method.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.method.ts @@ -5,7 +5,7 @@ import iam = require('@aws-cdk/aws-iam'); import cdk = require('@aws-cdk/core'); import { Test } from 'nodeunit'; import apigateway = require('../lib'); -import { ConnectionType, EmptyModel, ErrorModel } from '../lib'; +import { ConnectionType, JsonSchemaType, JsonSchemaVersion } from '../lib'; export = { 'default setup'(test: Test) { @@ -349,8 +349,8 @@ export = { 'method.response.header.errthing': true }, responseModels: { - 'application/json': new EmptyModel(), - 'text/plain': new ErrorModel() + 'application/json': apigateway.Model.EMPTY_MODEL, + 'text/plain': apigateway.Model.ERROR_MODEL } } ] @@ -443,6 +443,140 @@ export = { expect(stack).to(haveResource('AWS::ApiGateway::Method', { HttpMethod: "POST" })); expect(stack).to(haveResource('AWS::ApiGateway::Method', { HttpMethod: "GET" })); expect(stack).to(haveResource('AWS::ApiGateway::Method', { HttpMethod: "PUT" })); + test.done(); + }, + + 'requestModel can be set'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigateway.RestApi(stack, 'test-api', { deploy: false }); + const model = api.addModel('test-model', { + contentType: "application/json", + modelName: 'test-model', + schema: { + title: "test", + type: JsonSchemaType.OBJECT, + properties: { message: { type: JsonSchemaType.STRING } } + } + }); + + // WHEN + new apigateway.Method(stack, 'method-man', { + httpMethod: 'GET', + resource: api.root, + options: { + requestModels: { + "application/json": model + } + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + HttpMethod: 'GET', + RequestModels: { + "application/json": { Ref: stack.getLogicalId(model.node.findChild('Resource') as cdk.CfnElement) } + } + })); + + test.done(); + }, + + 'methodResponse has a mix of response modes'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigateway.RestApi(stack, 'test-api', { deploy: false }); + const htmlModel = api.addModel('my-model', { + schema: { + schema: JsonSchemaVersion.DRAFT4, + title: "test", + type: JsonSchemaType.OBJECT, + properties: { message: { type: JsonSchemaType.STRING } } + } + }); + + // WHEN + new apigateway.Method(stack, 'method-man', { + httpMethod: 'GET', + resource: api.root, + options: { + methodResponses: [{ + statusCode: '200' + }, { + statusCode: "400", + responseParameters: { + 'method.response.header.killerbees': false + } + }, { + statusCode: "500", + responseParameters: { + 'method.response.header.errthing': true + }, + responseModels: { + 'application/json': apigateway.Model.EMPTY_MODEL, + 'text/plain': apigateway.Model.ERROR_MODEL, + 'text/html': htmlModel + } + } + ] + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + HttpMethod: 'GET', + MethodResponses: [{ + StatusCode: "200" + }, { + StatusCode: "400", + ResponseParameters: { + 'method.response.header.killerbees': false + } + }, { + StatusCode: "500", + ResponseParameters: { + 'method.response.header.errthing': true + }, + ResponseModels: { + 'application/json': 'Empty', + 'text/plain': 'Error', + 'text/html': { Ref: stack.getLogicalId(htmlModel.node.findChild('Resource') as cdk.CfnElement) } + } + } + ] + })); + + test.done(); + }, + + 'method has a request validator'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigateway.RestApi(stack, 'test-api', { deploy: false }); + const validator = api.addRequestValidator('validator', { + validateRequestBody: true, + validateRequestParameters: false + }); + + // WHEN + new apigateway.Method(stack, 'method-man', { + httpMethod: 'GET', + resource: api.root, + options: { + requestValidator: validator + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + RequestValidatorId: { Ref: stack.getLogicalId(validator.node.findChild('Resource') as cdk.CfnElement) } + })); + expect(stack).to(haveResource('AWS::ApiGateway::RequestValidator', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as cdk.CfnElement) }, + ValidateRequestBody: true, + ValidateRequestParameters: false + })); + test.done(); } }; diff --git a/packages/@aws-cdk/aws-apigateway/test/test.model.ts b/packages/@aws-cdk/aws-apigateway/test/test.model.ts new file mode 100644 index 0000000000000..ec9f300372310 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/test.model.ts @@ -0,0 +1,75 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/core'); +import { Test } from 'nodeunit'; +import apigateway = require('../lib'); +import { JsonSchemaType, JsonSchemaVersion } from '../lib'; + +export = { + 'default setup'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigateway.RestApi(stack, 'test-api', { cloudWatchRole: false, deploy: true }); + new apigateway.Method(stack, 'my-method', { + httpMethod: 'POST', + resource: api.root, + }); + + // WHEN + new apigateway.Model(stack, 'my-model', { + restApi: api, + schema: { + schema: JsonSchemaVersion.DRAFT4, + title: "test", + type: JsonSchemaType.OBJECT, + properties: { message: { type: JsonSchemaType.STRING } } + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Model', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as cdk.CfnElement) }, + Schema: { + $schema: "http://json-schema.org/draft-04/schema#", + title: "test", + type: "object", + properties: { message: { type: "string" } } + } + })); + + test.done(); + }, + + 'no deployment'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigateway.RestApi(stack, 'test-api', { cloudWatchRole: false, deploy: true }); + new apigateway.Method(stack, 'my-method', { + httpMethod: 'POST', + resource: api.root, + }); + + // WHEN + new apigateway.Model(stack, 'my-model', { + restApi: api, + schema: { + schema: JsonSchemaVersion.DRAFT4, + title: "test", + type: JsonSchemaType.OBJECT, + properties: { message: { type: JsonSchemaType.STRING } } + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Model', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as cdk.CfnElement) }, + Schema: { + $schema: "http://json-schema.org/draft-04/schema#", + title: "test", + type: "object", + properties: { message: { type: "string" } } + } + })); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-apigateway/test/test.requestvalidator.ts b/packages/@aws-cdk/aws-apigateway/test/test.requestvalidator.ts new file mode 100644 index 0000000000000..1743c15ac8042 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/test.requestvalidator.ts @@ -0,0 +1,60 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/core'); +import { Test } from 'nodeunit'; +import apigateway = require('../lib'); + +export = { + 'default setup'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigateway.RestApi(stack, 'test-api', { cloudWatchRole: false, deploy: true }); + new apigateway.Method(stack, 'my-method', { + httpMethod: 'POST', + resource: api.root, + }); + + // WHEN + new apigateway.RequestValidator(stack, 'my-model', { + restApi: api, + validateRequestBody: true, + validateRequestParameters: false + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::RequestValidator', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as cdk.CfnElement) }, + ValidateRequestBody: true, + ValidateRequestParameters: false + })); + + test.done(); + }, + + 'no deployment'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigateway.RestApi(stack, 'test-api', { cloudWatchRole: false, deploy: false }); + new apigateway.Method(stack, 'my-method', { + httpMethod: 'POST', + resource: api.root, + }); + + // WHEN + new apigateway.RequestValidator(stack, 'my-model', { + restApi: api, + requestValidatorName: 'my-model', + validateRequestBody: false, + validateRequestParameters: true + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::RequestValidator', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as cdk.CfnElement) }, + Name: "my-model", + ValidateRequestBody: false, + ValidateRequestParameters: true + })); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts b/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts index 913aab33e8dea..d501a9dc5e9d9 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts @@ -1,15 +1,15 @@ import { expect, haveResource, haveResourceLike, ResourcePart } from '@aws-cdk/assert'; -import cdk = require('@aws-cdk/core'); -import { App, Stack } from '@aws-cdk/core'; +import { App, CfnElement, CfnResource, Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; import apigateway = require('../lib'); +import { JsonSchemaType, JsonSchemaVersion } from '../lib'; // tslint:disable:max-line-length export = { 'minimal setup'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); // WHEN const api = new apigateway.RestApi(stack, 'my-api'); @@ -100,12 +100,12 @@ export = { test.done(); }, - '"name" is defaulted to construct id'(test: Test) { + '"name" is defaulted to resource physical name'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); // WHEN - const api = new apigateway.RestApi(stack, 'my-first-api', { + const api = new apigateway.RestApi(stack, 'restapi', { deploy: false, cloudWatchRole: false, }); @@ -114,7 +114,7 @@ export = { // THEN expect(stack).to(haveResource('AWS::ApiGateway::RestApi', { - Name: "my-first-api" + Name: 'restapi' })); test.done(); @@ -137,7 +137,7 @@ export = { '"addResource" can be used on "IRestApiResource" to form a tree'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'restapi', { deploy: false, cloudWatchRole: false, @@ -172,7 +172,7 @@ export = { '"addResource" allows configuration of proxy paths'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'restapi', { deploy: false, cloudWatchRole: false, @@ -193,7 +193,7 @@ export = { '"addMethod" can be used to add methods to resources'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'restapi', { deploy: false, cloudWatchRole: false }); const r1 = api.root.addResource('r1'); @@ -269,7 +269,7 @@ export = { 'resourcePath returns the full path of the resource within the API'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'restapi'); // WHEN @@ -291,7 +291,7 @@ export = { 'resource path part validation'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'restapi'); // THEN @@ -305,7 +305,7 @@ export = { 'fails if "deployOptions" is set with "deploy" disabled'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); // THEN test.throws(() => new apigateway.RestApi(stack, 'myapi', { @@ -318,7 +318,7 @@ export = { 'CloudWatch role is created for API Gateway'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'myapi'); api.root.addMethod('GET'); @@ -330,7 +330,7 @@ export = { 'fromRestApiId'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); // WHEN const imported = apigateway.RestApi.fromRestApiId(stack, 'imported-api', 'api-rxt4498f'); @@ -342,7 +342,7 @@ export = { '"url" and "urlForPath" return the URL endpoints of the deployed API'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'api'); api.root.addMethod('GET'); @@ -374,7 +374,7 @@ export = { '"urlForPath" would not work if there is no deployment'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'api', { deploy: false }); api.root.addMethod('GET'); @@ -386,7 +386,7 @@ export = { '"urlForPath" requires that path will begin with "/"'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'api'); api.root.addMethod('GET'); @@ -397,7 +397,7 @@ export = { '"executeApiArn" returns the execute-api ARN for a resource/method'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'api'); api.root.addMethod('GET'); @@ -421,7 +421,7 @@ export = { '"executeApiArn" path must begin with "/"'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'api'); api.root.addMethod('GET'); @@ -432,7 +432,7 @@ export = { '"executeApiArn" will convert ANY to "*"'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'api'); const method = api.root.addMethod('ANY'); @@ -456,7 +456,7 @@ export = { '"endpointTypes" can be used to specify endpoint configuration for the api'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); // WHEN const api = new apigateway.RestApi(stack, 'api', { @@ -479,7 +479,7 @@ export = { '"cloneFrom" can be used to clone an existing API'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const cloneFrom = apigateway.RestApi.fromRestApiId(stack, 'RestApi', 'foobar'); // WHEN @@ -499,10 +499,10 @@ export = { 'allow taking a dependency on the rest api (includes deployment and stage)'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const api = new apigateway.RestApi(stack, 'myapi'); api.root.addMethod('GET'); - const resource = new cdk.CfnResource(stack, 'DependsOnRestApi', { type: 'My::Resource' }); + const resource = new CfnResource(stack, 'DependsOnRestApi', { type: 'My::Resource' }); // WHEN resource.node.addDependency(api); @@ -524,7 +524,7 @@ export = { 'defaultIntegration and defaultMethodOptions can be used at any level'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const rootInteg = new apigateway.AwsIntegration({ service: 's3', action: 'GetObject' @@ -601,4 +601,70 @@ export = { test.done(); }, + + 'addModel is supported'(test: Test) { + // GIVEN + const stack = new Stack(); + const api = new apigateway.RestApi(stack, 'myapi'); + api.root.addMethod('OPTIONS'); + + // WHEN + api.addModel('model', { + schema: { + schema: JsonSchemaVersion.DRAFT4, + title: "test", + type: JsonSchemaType.OBJECT, + properties: { message: { type: JsonSchemaType.STRING } } + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Model', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as CfnElement) }, + Schema: { + $schema: "http://json-schema.org/draft-04/schema#", + title: "test", + type: "object", + properties: { message: { type: "string" } } + } + })); + + test.done(); + }, + + 'addRequestValidator is supported'(test: Test) { + // GIVEN + const stack = new Stack(); + const api = new apigateway.RestApi(stack, 'myapi'); + api.root.addMethod('OPTIONS'); + + // WHEN + api.addRequestValidator('params-validator', { + requestValidatorName: 'Parameters', + validateRequestBody: false, + validateRequestParameters: true + }); + api.addRequestValidator('body-validator', { + requestValidatorName: "Body", + validateRequestBody: true, + validateRequestParameters: false + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::RequestValidator', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as CfnElement) }, + Name: "Parameters", + ValidateRequestBody: false, + ValidateRequestParameters: true + })); + + expect(stack).to(haveResource('AWS::ApiGateway::RequestValidator', { + RestApiId: { Ref: stack.getLogicalId(api.node.findChild('Resource') as CfnElement) }, + Name: "Body", + ValidateRequestBody: true, + ValidateRequestParameters: false + })); + + test.done(); + } };