API-X Endpoints are routes that handle an operation of an HTTP RESTful API, such as getting user data, creating a new post, among others.
The ApiXMethod
interface is used to define a route or endpoint for your HTTP RESTful API. Generally speaking, methods should be designed with the following key principles in mind:
ApiXMethod#requestorOwnsResource
.There are two properties that define the endpoint, entity
and method
. Entity is used to define a scope for your endpoint, e.g., users
or posts
, whereas method defines an operation within that scope, e.g., create
, delete
, or update
. Entities are optional, and you can define your endpoints as needed. The resulting endpoint is:
/<entity>/<method>
, if entity
is a non-empty string; or,/<method>
, if there's no entity.To choose appropriate entities and method names, think of two questions:
entity
.method
.For example, an operation to create a new post entry can have an entity of posts
and a method of create
or new
.
However, when getting specific resources, you can also use parameterized methods, e.g.,:
/users/:id
, where entity
is users
and method
is :id
. This method will match requests such as /users/01234567
, which is typical when getting data for a user with a given ID.The ApiXMethod
interface uses templates to allow the definition of the shape of input URL query parameters. Generally speaking, there are two steps to take:
An interface is used to define the schema of the URL parameters:
interface SearchPostsSchema extends ApiXRequestInputSchema {
readonly searchTerm: string; /// Search term to use
readonly sortBy?: PostKey; /// A key to sort by
readonly authorName?: string; /// An author for the posts
readonly dateRange: [Date, Date]; /// The date range of the post
readonly tags?: ReadonlyArray<string>; /// A list of tags the post must contain
}
The schema definition must extend the ApiXRequestInputSchema
interface. With this, you define that when the endpoint receives a request, only these URL query parameters will be used. Furthermore, only searchTerm
is required, and additional parameters are optional.
In addition to defining the schema, the parameters are defined using the queryParameters
array of ApiXMethod
. In this definition, you can specify validator and processor objects. These objects will do exactly as their names imply: validate and process query parameters.
As an example, let's write a validator and processor for the tags
query parameter:
/**
* A validator object that succeeds for correctly defined comma-separated
* arrays for URL parameters. Example:
* `/entity/endpoint?array=value0,value1,value2,value3`
*/
class CommaSeparatedArrayUrlQueryParameterValidator implements ApiXUrlQueryParameterValidator {
isValid(name: string, value: string): boolean {
/// Matches only alphanumeric, case-insensitive, comma-separated values
const regex = /^[a-zA-Z0-9]+(,[a-zA-Z0-9]+)*$/;
return regex.test(value);
}
}
This validator uses a simple regular expression pattern to validate that the value is an alphanumeric, case-insensitive, comma-separated list of strings. This will suffice for a list of tags. Now, a processor can be implemented:
/**
* A processor object that converts a valid list of comma-separated values
* to a readonly array.
*/
class CommaSeparatedArrayUrlQueryParameterProcessor implements ApiXUrlQueryParameterProcessor<ReadonlyArray<string>> {
process(name: string, value: string): [string, ReadonlyArray<string>] {
return [name, value.split(',')];
}
}
The ApiXUrlQueryParameterProcessor
is a templated interface that returns a key-value tuple, where the value can have any type. This makes it useful to define parameters in the schema with any desired type, and the processor handles the conversion into that type. In this case, the parameter value would be a string like 'value0,value1,value2'
, but the output of the processor is a ReadonlyArray<string>
, namely ['value0', 'value1', 'value2']
, which matches the type of the tags
property in the SearchPostsSchema
interface.
Additionally, a processor may decide to process the name of the parameter so that you have the additional flexibility of allowing users to specify URL query parameters using one convention, e.g., /entity/method/snake_case=some_value
, but the schema may receive the property as snakeCase
instead by modifying it in the processor.
Finally, with a validator and processor implemented, the parameters can be defined in the method:
const searchPostsMethod: ApiXMethod<SearchPostsSchema> = {
entity: 'posts',
method: 'search',
queryParameters: [
/* the processor will convert `search_query` into `searchTerm` */
new ApiXUrlQueryParameter('search_query', someValidator, someProcessor, true /* required */),
new ApiXUrlQueryParameter('sort_by', someValidator, someProcessor, /* not required */),
new ApiXUrlQueryParameter('author', someValidator, someProcessor, /* not required */),
new ApiXUrlQueryParameter('date_range', someValidator, someProcessor, /* not required */),
new ApiXUrlQueryParameter('tags', new CommaSeparatedArrayUrlQueryParameterValidator(), new CommaSeparatedArrayUrlQueryParameterProcessor(), /* not required */),
],
requestHandler: (req, res) => {
/* `req` has an implicit type of `ApiXRequestHandler<SearchPostsSchema>` */
const queryParameters = req.queryParameters;
/* Now each parameter in the `queryParameters` object is fully validated
and processed to be in its optional form */
if (queryParameters.tags && queryParameters.tags.length > 0) {
/// do something with the tags array
}
return {
data: someJsonObject
};
},
characteristics: new Set([ApiXMethodCharacteristic.PublicUnownedData])
};
Note: if a validator object fails, the request will immediately fail and an unsuccessful response will be returned.
The ApiXMethod
interface's second template parameter is used to define the schema of the JSON body in a similar fashion to how it's done for URL queries.
interface Post {
readonly id: string;
readonly content: string;
readonly authorId: string;
}
interface CreatePostRequestSchema extends ApiXRequestInputSchema {
readonly post: Post;
readonly tags?: ReadonlyArray<string>;
}
In this case, the schema to be used is CreatePostRequestSchema
. A validator can then be used to ensure each post is valid:
class CreatePostRequestValidator implements ApiXHttpBodyValidator<CreatePostRequestSchema> {
isValid(body: CreatePostRequestSchema): boolean {
/// validate post here. Returning false means
/// the body is not valid and the request is rejected.
return true;
}
}
Then the method can be defined:
const createPostMethod: ApiXMethod<Record<string, never>, CreatePostRequestSchema> = {
entity: 'posts',
method: 'new',
jsonBodyRequired: true, /// This endpoint requires that the body be present
jsonBodyValidator: new CreatePostRequestValidator(),
requestHandler: async (req, res) => {
/* `req` has an implicit type of `ApiXRequestHandler<Record<string, never>, CreatePostRequestSchema>` */
/// jsonBody is of type `CreatePostRequestSchema`
/// Since `jsonBodyRequired` is `true`, it's guaranteed
/// that this will have a value.
const jsonBody = req.jsonBody!;
const response = await newPost(jsonBody.post);
return {
data: response
};
},
characteristics: new Set([ApiXMethodCharacteristic.PublicOwnedData]),
requestorOwnsResource: () => true // the creator owns the resource they created
};
Note: since this method doesn't require URL query parameters, that template is set to Record<string, never>
and it will not be present.
Below is an example ApiXMethod
for a GET
request to retrieve a private post. This endpoint uses the PrivateOwnedData
characteristic to determine if the requestor owns the resource they are trying to access.
const getPrivatePostMethod: ApiXMethod = {
entity: 'posts',
method: 'private/:id',
httpMethod: 'GET',
characteristics: new Set([ApiXMethodCharacteristic.PrivateOwnedData]),
requestorOwnsResource: async (req) => {
const resourceId = req.params.id as string;
const userData = DataManager.shared.getAuthenticatedUser(req);
const post = await DataManager.shared.getPost(resourceId);
return post && post.userId === userData?.id;
},
requestHandler: async (req, res) => {
const resourceId = req.params.id as string;
const post = await DataManager.shared.getPost(resourceId);
if (!post) {
return {
status: 404,
data: utils.makeApiXErrorResponse(`Post not found!`)
};
}
return {
data: {
success: true,
post
}
};
},
};