APIs
Nitric has built-in support for web apps and HTTP API development. The api
resource allows you to create APIs in your applications, including routing, middleware and request handlers.
Creating APIs
Nitric allows you define named APIs, each with their own routes, middleware, handlers and security.
Here's an example of how to create a new API with Nitric:
import { api } from '@nitric/sdk'
// each API needs a unique name
const galaxyApi = api('far-away-galaxy-api')
galaxyApi.get('/moon', async ({ req, res }) => {
res.body = "that's no moon, it's a space station."
})
Routing
You can define routes and handler services for incoming requests using methods on your API objects.
For example, you can declare a route that handles POST
requests using the post()
method. When declaring routes you provide the path to match and a callback that will serve as the handler for matching requests.
Depending on the language SDK, callbacks are either passed as parameters or defined using decorators.
import { getPlanetList, createPlanet } from 'planets'
galaxyApi.get('/planets', async (ctx) => {
ctx.res.json(getPlanetList())
})
galaxyApi.post('/planets', async (ctx) => {
createPlanet(ctx.req.json())
ctx.res.status = 201
})
Request Context
Nitric provides callbacks with a single context object that gives you everything you need to read requests and write responses. By convention this object is typically named ctx
.
The context object includes a request req
and response res
, which in turn provide convenient methods for reading and writing bodies, as well as auto-extracted parameters and HTTP headers.
Parameters
The path string used to declare routes can include named parameters. The values collected from those parameters are automatically included in the context object under ctx.req.params
.
:
import { getPlanet } from 'planets'
// create a dynamic route and extract the parameter `name`
galaxyApi.get('/planets/:name', async (ctx) => {
const { name } = ctx.req.params
ctx.res.json(getPlanet(name))
})
HTTP status and headers
The response object provides status
and headers
properties you can use to return HTTP status codes and headers.
// return a redirect response using status 301
galaxyApi.get('/planets/alderaan', async (ctx) => {
ctx.res.status = 301
ctx.res.headers['Location'] = ['https://example.org/debris/alderaan']
})
API Security
APIs can include security definitions for OIDC-compatible providers such as Auth0, FusionAuth and AWS Cognito.
Applying security at the API allows AWS, Google Cloud and Azure to reject unauthenticated or unauthorized requests at the API Gateway, before invoking your application code. In serverless environments this reduces costs by limiting application load from unwanted or malicious requests.
Authentication
APIs can be configured to automatically authenticate and allow or reject incoming requests. A securityDefinitions
object can be provided, which defines the authentication requirements that can later be enforced by the API.
The security definition describes the kind of authentication to perform and the configuration required to perform it. For a JWT this configuration includes the JWT issuer and audiences.
Security definitions only define available security requirements for an API, they don't automatically apply those requirements.
Once a security definition is available it can be applied to the entire API or selectively to individual routes.
import { api } from '@nitric/sdk'
const secureApi = api('main', {
// declare security definition named 'default'.
securityDefinitions: {
default: {
kind: 'jwt',
issuer: 'https://dev-abc123.us.auth0.com',
audiences: ['https://test-security-definition/'],
},
},
// apply the 'default' security definition to all routes in this API.
security: {
default: [],
},
})
Authorization
In addition to authentication, Nitric APIs can also be configured to perform authorization based on scopes. Again, this can be done at the top level of the API or on individual routes.
Add the required scopes to the security
object when applying a security definition.
const helloApi = api('main', {
security: {
// only authorize requests with the 'user.read' scope
user: ['user.read'],
},
securityDefinitions: {
user: {
kind: 'jwt',
issuer: 'https://dev-abc123.us.auth0.com',
audiences: ['https://test-security-definition/'],
},
},
})
For an in-depth tutorial look at the Auth0 integration guide
Override API-level security
Individual routes can have their own security rules which apply any available securityDefinition
. The requirement defined on routes override any requirements previously set at the top level of the API.
This allows you to selectively increase or decrease the security requirements for specific routes.
galaxyApi.get('planets/unsecured-planet', async (ctx) => {}, {
// override top level security to remove security from this route
security: {},
})
galaxyApi.post('planets/secured-planet', async (ctx) => {}, {
// override top level security to require user.write scope to access
security: {
user: ['user.write'],
},
})
Defining Middleware
Behavior that's common to several APIs or routes can be applied using middleware services. Multiple middleware can also be composed to create a cascading set of steps to perform on incoming requests or outgoing responses.
Middleware services look nearly identical to handlers except for an additional parameter called next
, which is the next middleware or handler to be called in the chain. By providing each middleware the next middleware in the chain it allows them to intercept requests, response and errors to perform operations like logging, decoration, exception handling and many other common tasks.
async function validate(ctx, next) {
if (!ctx.req.headers['content-type']) {
ctx.res.status = 400
ctx.res.body = 'header Content-Type is required'
return ctx
}
return await next(ctx)
}
API level middleware
Middleware defined at the API level will be called on every request to every route.
import { api } from '@nitric/sdk'
import { validate, logRequest } from '../middleware'
const customersApi = api('customers', {
middleware: [logRequest, validate],
})
Route level middleware
Middleware defined at the route level will only be called for that route.
import { api } from '@nitric/sdk'
import { validate } from '../middleware'
const customersApi = api('customers')
const getAllCustomers = (ctx) => {}
// Inline using .get()
customersApi.get('/customers', [validate, getAllCustomers])
// Using .route()
customersApi.route('/customers').get([validate, getAllCustomers])
Custom Domains
By default APIs deployed by Nitric will be assigned a domain by the target cloud provider. If you would like to deploy APIs with predefined custom domains you can specify the custom domains for each API in your project's stack files. For these domains to be successfully configured you will need to meet the prerequisites defined for each cloud provider below.
name: my-aws-stack
provider: nitric/aws@1.0.0
region: ap-southeast-2
# Add a key for configuring apis
apis:
# Target an API by its nitric name
my-api-name:
# provide domains to be used for the api
domains:
- test.example.com
AWS Custom Domain Prerequisites
To support custom domains with APIs deployed to AWS your domain (or subdomain) will need to be setup as a hosted zone in Route 53.
The general steps to setup a hosted zone in Route 53 are as follows:
- Navigate to Route 53 in the AWS Console
- Select 'hosted zones' from the left navigation
- Click 'Create hosted zone'
- Enter your domain name and choose the 'Public hosted zone' type.
- Click 'Create hosted zone'
- You will now be provided with a set of NS DNS records to configure in the DNS provider for your domain
- Create the required DNS records, then wait for the DNS changes to propagate
Once this is done you will be able to use the hosted zone domain or any direct subdomain with your Nitric APIs.
You can read more about how AWS suggests configuring hosted zones in their documentation on Making Route 53 the DNS service for a domain that's in use or Making Route 53 the DNS service for an inactive domain
If the hosted zone was nitric.io
, nitric.io
or api.nitric.io
would be
supported for APIs, but not public.api.nitric.io
since that is a subdomain
of a subdomain.
DNS propagation of the NS records can take a few seconds to a few hours due to the nature of DNS.
If you're more of a visual learner, below is a video that walks through how to set up your custom domains.
Serving from multiple files
Nitric APIs are scoped to the project and can be referenced from multiple services
. This allows you to choose the granularity of services that suites your project. For small projects you might have a single service that serves all routes, while in larger projects multiple services might combine to serve paths and methods for your API.
Using the same resource name
Since resource names are unique across each Nitric project, you can access a resource in multiple locations by simply reusing it's name. Here's an example of services in different files serving different paths on the same API.
import { api } from '@nitric/sdk'
const accountsApi = api('accounts')
accountsApi.get('/users/:id', async () => {
// your logic here
})
import { api } from '@nitric/sdk'
const accountsApi = api('accounts')
accountsApi.get('/orgs/:id', async () => {
// your logic here
})
Calling api()
multiple times with the same name returns the same API
resource each time, allowing it to be referenced in multiple services.
Importing an existing resource
While reusing a name is useful, it can lead to errors due to typos or when the configuration of the resource is complex. For this reason it's often preferable to declare the resource in a shared location and import it into the services as needed.
Here is the same example enhanced to import a common API resource.
import { api } from '@nitric/sdk'
export const accountsApi = api('accounts')
import { accountsApi } from '../resources'
accountsApi.get('/users/:id', async () => {
// your logic here
})
import { accountsApi } from '../resources'
accountsApi.get('/orgs/:id', async () => {
// your logic here
})