@alien-rpc/service v0.2.5
@alien-rpc/service
This package is used by your backend code to define your API routes. It's a simplistic library without many features, as it relies on other libraries (e.g. hattip, typebox, and pathic) to handle the heavy lifting.
The rest of this document will teach you how to define routes, document them, validate their input/output data, and expose them over HTTP.
Defining routes
First, let's define a GET route. We'll need a path pattern (a string literal) and a route handler (a plain old JavaScript function).
import { route } from '@alien-rpc/service'
export const getUser = route.get('/users/:id', async ({ id }) => {
// TODO: return some JSON-compatible data
})For more on the path pattern syntax, see the “Path patterns” section of the Pathic readme.
Your routes can be declared anywhere in your codebase, as long as they are exported and the alien-rpc generator is told where to find them. It's best practice to have dedicated modules for your routes (i.e. avoid declaring them alongside other exports that aren't routes).
If you prefer all-caps HTTP methods, you can use the exported route.GET function instead. There is no functional difference, but it's a matter of personal preference.
Route Arguments
If a route has path parameters, its handler will have 3 arguments (pathParams, requestData, ctx). Otherwise, it will have 2 (requestData, ctx). The requestData is an object of either the route's search parameters (for GET/DELETE/HEAD routes) or its JSON body (for POST/PUT/PATCH routes). The ctx argument is the request context, as defined by hattip.
Path parameters
If a route has exactly 1 path parameter, its pathParams argument will be a single value. If a route has multiple path parameters, pathParams will be an array of values. If you don't explicitly type the pathParams argument, each parameter value is typed as a string.
const getUser = route.get('/users/:id', async id => {
// typeof id === 'string'
})
const getUserInGroup = route.get(
'/groups/:groupId/users/:userId',
async ([groupId, userId]) => {
// typeof groupId === 'string'
// typeof userId === 'string'
}
)Supported HTTP methods
The following HTTP methods are supported:
GETPOSTPUTPATCHDELETEOPTIONS
No need to manually define this route, as it's handled internally.HEAD
While you can define a HEAD route, your GET routes will also match HEAD requests. You can check for this in your route handler viactx.request.method === "HEAD". Even if your route handler returns a response body, it will be ignored for HEAD requests.
Documenting routes
Routes can be documented like any TypeScript function.
/**
* Retrieve the public profile for a given user.
*
* @param id - The ID of the user to retrieve.
*/
const getUser = route.get('/users/:id', async id => {
// ...
})This documentation is extracted by @alien-rpc/generator and included with the client's type definitions.
Currently, this works for routes, but not their path parameters or request data. This feature is being tracked in #3 (contributions welcome).
Runtime validation
TypeScript types are used by @alien-rpc/generator to determine how your route's path parameters and request data should be validated at runtime.
So if your getUser route is expecting the id path parameter to be a number and the includePrivate search parameter to be a boolean, you can define it like this:
import { route } from '@alien-rpc/service'
const getUser = route.get(
'/users/:id',
async (id: number, searchParams: { includePrivate?: boolean }) => {
// ...
}
)Note that you can also explicitly type your request data, which in case you forgot, is the 2nd argument to your route handler that represents the JSON request body (for POST/PUT/PATCH routes) or search parameters (for GET/HEAD/DELETE routes).
Path parameter limitations
Path parameters can only be one of the following types: string, number, or an array of those types. In the case of an array, the path parameter will be split by any / characters within it, which is probably only useful for wildcard parameters (e.g. /files/*filename).
Date parsing
You may use the Date type for your “request data” to parse a string into a Date object. As implied in the previous section, this is not supported for path parameters.
This even works for POST/PUT/PATCH request bodies, which use JSON encoding. Basically, the date will be serialized to the ISO-8601 format during transport, and parsed back into a Date object upon arrival.
Type constraints
Sometimes, TypeScript types aren't strict enough for your use case. For example, you might expect the id parameter of your getUser route to be an integer greater than 0.
For this, you can use the “type tags” feature:
import { route, t } from '@alien-rpc/service'
const getUser = route.get(
'/users/:id',
async ({ id }: { id: number & t.MultipleOf<1> & t.Minimum<1> }) => {
// ...
}
)Type constraints are supported everywhere TypeScript types are supported, including path parameters, request data, and response data.
The Type Constraints page has more information on the available constraints.
Request context
The request context is the last argument of your route handler. It's an object containing information about the incoming request, such as the request method, headers, and URL. See here for a complete list of properties and methods in the RequestContext type.
Note that your route handler always receives an object representing the request data (either search parameters or JSON body). Therefore, to access the request context, you need to declare an argument name for the request data first. See _data in the example below:
export const getApplStockPrice = route.get(
'/stocks/appl',
async (_data, ctx) => {
ctx.url // => [object URL]
ctx.request.url // => "/stocks/appl"
ctx.request.headers // => [object Headers]
}
)Response manipulation
The request context contains a response object property with a status number and a headers object. You can modify these properties to customize the HTTP response.
export const getFile = route.get('/files/:id', async (id, _, ctx) => {
ctx.response.status = 200 // Note: The default status is 200
ctx.response.headers.set('Content-Type', 'application/pdf')
ctx.response.headers.set(
'Content-Disposition',
'attachment; filename="file.pdf"'
)
return await getFileContents(id)
})
Exposing routes over HTTP
The compileRoutes function creates a middleware function that can be used with hattip. It expects an array of route definitions, which are located wherever you set --serverOutFile to when running @alien-rpc/generator through the CLI (it defaults to ./server/generated/api.ts).
import { compose } from '@hattip/compose'
import { compileRoutes } from '@alien-rpc/service'
import routes from './server/generated/api.js'
export default compose(
loggingMiddleware(), // <-- runs before your routes
compileRoutes(routes),
ssrMiddleware() // <-- runs after your routes
)!NOTE In the example above, the
loggingMiddlewareandssrMiddlewareare hypothetical. Creating your own middleware is as easy as declaring a function (optionallyasync) that receives aRequestContextobject and returns one of the following: aResponseobject, any object with atoResponsemethod, or nothing (akavoid).
If you save the code above in the ./server/handler.ts module, you could start your server in the ./server/main.ts module like this:
import { createServer } from '@hattip/adapter-uwebsockets'
import handler from './handler.js'
createServer(handler).listen(3000, 'localhost', () => {
console.log('Server listening on http://localhost:3000')
})
Error handling
Currently, error handling is performed by the compileRoutes function.
Errors thrown by your route handlers are assumed to be unintentional, unless you throw an HttpError instance like so:
import { route, UnauthorizedError } from '@alien-rpc/service'
export const getPrivateProfile = route.get('/users/:id/private', async id => {
throw new UnauthorizedError()
})For more details, see the HTTP errors page.
Streaming responses
If you want to stream JSON to the client, define your route handler as an async generator:
import { route } from '@alien-rpc/service'
export const streamPosts = route.get('/posts', async function* () {
yield { id: 1, title: 'First post' }
yield { id: 2, title: 'Second post' }
})This takes advantage of the JSON Text Sequence format. Any JSON-compatible data can be yielded by your route handler. This allows the client to start receiving data before the route handler has finished executing.
Pagination
The paginate function allows you to provide pagination links in the response. This is only supported for routes whose handler is an async generator.
Please note that only GET routes support pagination.
The paginate function takes two arguments:
route: A reference to the current route (viathis) or another route (by the identifier you exported it with)links: An object with theprevandnextpagination links, which must provide an object containing path parameters and/or search parameters for the next/previous set of results
You must return the paginate function's result from your route handler.
import { route, paginate } from '@alien-rpc/service'
export const streamPosts = route.get(
'/posts',
async function* ({ offset, limit }: { offset: number; limit: number }) {
let count = 0
for await (const post of db.posts.find({ offset, limit })) {
yield post
count++
}
return paginate(this, {
prev: offset > 0 ? { offset: offset - limit, limit } : null,
next: count === limit ? { offset: offset + limit, limit } : null,
})
}
)Pagination is an optional feature. It not only supports an offset+limit style of pagination, but any other kind, like cursor-based pagination. When calling a paginated route through the alien-rpc client, two methods (previousPage and nextPage) are added to the ResponseStream object returned by that route's client method.
Streaming arbitrary data
If you need to stream data that isn't JSON, your route's handler needs to return a Response object whose body is a ReadableStream.
import { route } from '@alien-rpc/service'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { Readable } from 'node:stream'
export const downloadFile = route.get('/files/*filename', async filename => {
const fileStream = fs.createReadStream(path.join('./uploads', filename))
return new Response(Readable.toWeb(fileStream), {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
})
Conclusion
This concludes the documentation for @alien-rpc/service. Be sure to check out the documentation for the other packages in this library:
- alien-rpc An umbrella package containing the CLI, generator, client, and service packages
- @alien-rpc/generator The code generator for your API routes
- @alien-rpc/client The HTTP client for your API
If you still have questions, please open an issue and I'll do my best to help you out.
9 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
9 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
10 months ago
1 year ago
1 year ago