@serverless-contracts/core
Generate and use type-safe contracts between your Serverless services.
This package is part of the serverless-contracts project. See its documentation for more insights.
Installation
npm install @serverless-contracts/core
or if using yarn
yarn add @serverless-contracts/core
Defining contracts
ApiGateway
ApiGateway is an AWS service that makes it possible to trigger lambda functions through HTTP. There are two types of ApiGateways (for more details, see AWS documentation):
In our examples, we will use and HTTP API, but it is completely equivalent for REST APIs in terms of contracts.
Let's create our first HttpApi contract. First we will need to define the subschemas for each part of our contract:
- the
id
serves to uniquely identify the contract among all stacks. Please note that this id MUST be unique among all stacks. Use a convention to ensure unicity. - the
path
and the http method
which will trigger the lambda - the
integrationType
: "httpApi"
or "restApi"
- then the different parts of the http request:
- the path parameters:
pathParametersSchema
, which must correspond to a Record<string, string>
- the query string parameters:
queryStringParametersSchema
, which must respect the same constraint - the headers:
headersSchema
, with the same constraint - the body
bodySchema
which is an unconstrained JSON schema
- finally, the
outputSchema
in order to be able to validate the output of the lambda. It is also an unconstrained JSON schema.
const pathParametersSchema = {
type: 'object',
properties: { userId: { type: 'string' }, pageNumber: { type: 'string' } },
required: ['userId', 'pageNumber'],
additionalProperties: false,
} as const;
const queryStringParametersSchema = {
type: 'object',
properties: { testId: { type: 'string' } },
required: ['testId'],
additionalProperties: false,
} as const;
const headersSchema = {
type: 'object',
properties: { myHeader: { type: 'string' } },
required: ['myHeader'],
} as const;
const bodySchema = {
type: 'object',
properties: { foo: { type: 'string' } },
required: ['foo'],
} as const;
const outputSchema = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
required: ['id', 'name'],
} as const;
const myContract = new ApiGatewayContract({
id: 'my-unique-id',
path: '/users/{userId}',
method: 'GET',
integrationType: 'httpApi',
pathParametersSchema,
queryStringParametersSchema,
headersSchema,
bodySchema,
outputSchema,
});
Please note:
In order to properly use Typescript's type inference:
- All the schemas MUST be created using the
as const
directive. For more information, see json-schema-to-ts - If you do not wish to use one of the subschemas, you need to explicitely set it as
undefined
in the contract. For example, in order to define a contract without headers, we need to create it with:
const myContract = new ApiGatewayContract({
id: 'my-unique-id',
path: '/users/{userId}',
method: 'GET',
integrationType: 'httpApi',
pathParametersSchema,
queryStringParametersSchema,
headersSchema: undefined,
bodySchema,
outputSchema,
});
Provider-side usage
Generate the lambda trigger
In the config.ts
file of our lambda, in the events
section, we need to use the generated trigger to define the path and method that will trigger the lambda:
export default {
environment: {},
handler: getHandlerPath(__dirname),
events: [myContract.trigger],
};
This will only output the method
and path
. However, if you need a more fine-grained configuration for your lambda (such as defining an authorizer), you can use the getCompleteTrigger
method.
export default {
environment: {},
handler: getHandlerPath(__dirname),
events: [myContract.getCompleteTrigger({ authorizer: 'arn::aws...' })],
};
The static typing helps here to prevent accidental overloading of path
and method
:
export default {
environment: {},
handler: getHandlerPath(__dirname),
events: [
myContract.getCompleteTrigger({
method: 'delete',
}),
],
};
Validate the lambda
JSON Schemas are compatible with ajv
and @middy/validator
. You can use
myContract.inputSchema;
and
myContract.outputSchema;
in order to validate the input and/or the output of your lambda.
Type the lambda input and output
On the handler side, you can use the handler
method on the contract to correctly infer the input and output types from the schema.
const handler = myContract.handler(async event => {
event.pathParameters.userId;
event.toto;
event.pathParameters.toto;
return { id: 'coucou', name: 'coucou' };
});
Consumer-side usage
Simply call the axiosRequest
method on the schema.
await myContract.axiosRequest('https://my-site.com', {
pathParameters: { userId: '15', pageNumber: '45' },
headers: {
myHeader: 'hello',
},
queryStringParameters: { testId: 'plop' },
body: { foo: 'bar' },
});
All parameter types will be inferred from the schemas.
The return type will be an axios response of the type inferred from the outputSchema
.
If you do not wish to use axios
, you can use the type inference to generate request parameters with:
myContract.getRequestParameters({
pathParameters: { userId: '15', pageNumber: '45' },
headers: {
myHeader: 'hello',
},
queryStringParameters: { testId: 'plop' },
body: { foo: 'bar' },
});
and then use them in your request.
CloudFormation
AWS CloudFormation is used by the Serverless Framework to manage resources. In certain cases, it may be necessary to share these resources between services. For example, authentication may be handled by a common authorizer, which should not be reimplemented on each service.
The CloudFormation import/export syntax is very specific, but only one information is truly useful: the name of the export. This must be unique across CloudFormation stacks and serves as a global variable name for the related value.
Defining a CloudFormation contract
import { CloudFormationContract } from '@serverless-contracts/core';
const myCloudFormationContract = new CloudFormationContract({
name: 'mySuperExport',
});
Please note that here the export name is 'mySuperExport'
, and this value must be unique across stacks.
Using a CloudFormation contract to export a value
In the provider serverless.ts
, add an Outputs
key
const serverlessConfiguration = {
service: "my-provider-service",
provider: {...},
functions: {...},
resources: {
Resources: {...}
Outputs: {
MyAwesomeExport: myCloudFormationContract.exportValue({
description: 'A nice description',
value: { Ref: 'MyResourceLogicalId' },
}),
},
},
};
Please note:
- The
Ref
function is here an example, the CloudFormationContract
is compatible with all CloudFormation functions. Please refer to the documentation for more examples - Here, the
MyAwesomeExport
key has no importance and is not taken into account for the export
Using a CloudFormation contract to import a value
In the consumer serverless.ts
, you can use the import with:
const serverlessConfiguration = {
service: 'my-consumer-service',
functions: {...},
custom: {
myImportedValue: myCloudFormationContract.importValue,
},
};
The resolved imported value will be available as ${self:custom.myImportedValue}
in your serverless files. See the Serverless variables documentation.
About type inference
TODO