astro-typed-api v0.5.0
astro-typed-api ⌨️
This Astro integration offers a way to create type-safe API routes with no set-up and minimal concepts to learn.
https://github.com/lilnasy/gratelets/assets/69170106/570bcf7b-8331-4a83-8731-a3628d8c80de
Why astro-typed-api?
Astro's API routes are a great way to serve dynamic content. However, they are completely detached from your front-end code. The responsibility of serializing and deserializing data is left to the developer, and there is no indication whether a refactor in API design is going to break some UI feature.
This integration aims to solve these problems by providing a type-safe api
object that is aware of the input and return types of your API routes. Inline with the Astro philosophy, it does this while introducing minimum concepts to learn.
Installation
Manual Install
First, install the astro-typed-api
package using your package manager. If you're using npm or aren't sure, run this in the terminal:
npm install astro-typed-api
Then, apply this integration to your astro.config.*
file using the integrations
property:
// astro.config.mjs
import { defineConfig } from 'astro/config';
+ import typedApi from 'astro-typed-api';
export default defineConfig({
// ...
integrations: [typedApi()],
// ^^^^^^^^
});
Usage
Typed API routes are created using the defineApiRoute()
function, which are then exported the same way that normal API routes are in Astro.
// src/pages/api/hello.ts
import { defineApiRoute } from "astro-typed-api/server"
export const GET = defineApiRoute({
fetch: (name: string) => `Hello, ${name}!`
})
The defineApiRoute()
function takes an object with a fetch
method. The fetch
method will be called when an HTTP request is routed to the current endpoint. Parsing the request for structured data and converting the returned value to a response is handled automatically. Once defined, the API route becomes available for browser-side code to use on the api
object exported from astro-typed-api/client
:
---
// src/pages/index.astro
---
<script>
import { api } from "astro-typed-api/client"
const message = await api.hello.GET.fetch("lilnasy")
console.log(message) // "Hello, lilnasy!"
</script>
When the fetch
method is called on the browser, the arguments passed to it are serialized as query parameters and a GET
HTTP request is made to the Astro server. The result is deserialized from the response and returned by the call.
Note that only endpoints within the src/pages/api
directory are exposed on the api
object. Additionally, the endpoints must all be typescript files. For example, src/pages/x.ts
and src/pages/api/x.js
will not be made available to astro-typed-api/client
.
Type-safety
Types for newly created endpoints are automatically added to the api
object while astro dev
is running. You can also run astro sync
to update the types.
Typed API stores the generated types inside .astro
directory in the root of your project. The files here are automatically created, updated and used.
Input Validation
defineApiRoute()
also accepts a zod schema in the definition.
// src/pages/api/validatedHello.ts
import { defineApiRoute } from "astro-typed-api/server"
import { z } from "zod"
export const GET = defineApiRoute({
schema: z.object({
user: z.string(),
}),
fetch: ({ user }) => `Hello, ${user}!`,
)
When provided, the schema is used to validate the input passed to the fetch
method. If the arguments are invalid, the API route returns a 500
response and the client-side call will throw. Additionally, the input type will be inferred from the schema.
Using middleware locals
The fetch()
method is provided Astro's APIContext as its second argument. This allows you to read any locals that have been set in a middleware.
// src/pages/api/adminOnly.ts
import { defineApiRoute } from "astro-typed-api/server"
export const POST = defineApiRoute({
fetch: (name: string, { locals }) => {
const { user } = locals
if (!user) throw new Error("Visitor is not logged in.")
if (!user.admin) throw new Error("User is not an admin.")
...
}
)
Setting cookies
The APIContext
object also includes a set of utility functions for managing cookies which has the same interface as Astro.cookies
.
// src/pages/api/setPreferences.ts
import { defineApiRoute } from "astro-typed-api/server"
export const PATCH = defineApiRoute({
schema: z.object({
theme: z.enum(["light", "dark"]),
}),
fetch: ({ theme }, { cookies }) => {
cookies.set("theme", theme)
}
)
Adding response headers
The TypedAPIContext
object extends APIContext
by also including a response
property which can be used to send additional headers to the browser and CDNs.
// src/pages/api/cached.ts
import { defineApiRoute } from "astro-typed-api/server"
export const GET = defineApiRoute({
fetch: (_, { response }) => {
response.headers.set("Cache-Control", "max-age=3600")
return "Hello, world!"
}
)
Adding request headers
The client-side fetch()
method on the api
object accepts the same options as the global fetch
as its second argument. It can be used to set request headers.
---
// src/pages/index.astro
---
<script>
import { api } from "astro-typed-api/client"
const message = await api.cached.GET.fetch(undefined, {
headers: {
"Cache-Control": "no-cache",
}
})
</script>
Usage with React
Typed API does not include a hook of its own. However, it can be used with any React hook library that works with async functions. The following examples shows its usage with swr
.
import useSWR from 'swr'
function Profile() {
const { data, error, isLoading } = useSWR('getUser', api.user.GET.fetch)
if (error) return <div>failed to load</div>
if (isLoading) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
Errors
Failures are everywhere and errors are a form of communication around them. As an application developer, you want to be able to understand the underlying reason. Further, you want to communicate it to the user in terms that matter to them.
With this motivation, Typed API implements a usable and practical error handling system with two goals: 1. To provide a way for the developer to understand the cause. 2. To provide a convenient way to inform the user about the relevant details.
As an additional goal, Typed API aims to be secure by default. Errors intended to be read by developers (goal #1) are potentially exploitable when read by users (goal #2). As such, Typed API ensures that there is no ambiguity between the two. Information is never automatically sent to the clients; the details of the failure relevant to the user are explicitly provided by the developer.
The library meets these goals by maintaining a small set of documented errors (see Client-side errors and Server-side errors), and by providing an opt-in type-safe bridge for errors between the server and the client (see Custom error handling.)
Custom Error Handling
You can send custom error messages to the client by calling context.error()
and returning its value as the result of the handler.
// src/pages/api/search.ts
import { defineApiRoute } from "astro-typed-api/server"
export const GET = defineApiRoute({
fetch({ query, page }: { query: string, page?: number }, { locals, error }) {
if (locals.loginInfo.expires < Date.now()) {
// notice that the error is returned, not thrown
return error("session expired")
}
return [ "search result 1", "search result 2" ]
}
})
To mantain type-level information about the errors, custom errors must be returned from the fetch handler, not thrown.
On the client-side, you will notice that the return type of the fetch call is unchanged. This is because the error details are only accessible as a CustomError
within the catch()
handler. This allows keeping the code responsible for normal behavior clean and simple by separating the error handling from the "happy path".
import { api } from "astro-typed-api/client"
const data = await api.search.GET.fetch({ query: "science" }).catch(error => {
if (error.name === "TypedAPI.CustomError") {
console.log(error.type) // TypeScript knows this is "session expired"
if (error.type === "session expired") {
toast.error({
message: "Your session has expired. Please log in again.",
action: () => redirectToLogin()
})
}
}
})
In addition to the reason
, you can send a user-readable message directly in the error()
call by providing an object with reason
and message
fields:
import { defineApiRoute } from "astro-typed-api/server"
export const GET = defineApiRoute({
fetch: (_, { error }) => {
return error({
reason: "session expired",
message: "Your session has expired. Please log in again."
})
}
})
Under the hood, the error is serialized by using the headers X-Typed-Error
and X-Typed-Message
for the reason
and message
fields respectively.
By default, the HTTP response sent as a result of the error has a 500
status code. However, if you are using a data fetching library, the status code maybe relevant to whether the request is retried. In this case, you can set the status code explicitly by passing it as the second argument to the error()
function.
import { defineApiRoute } from "astro-typed-api/server"
export const GET = defineApiRoute({
fetch: (_, { error }) => {
return error("try again", { status: 403 })
}
})
Serialization
By default, Typed API uses JSON to send data over the network. This keeps the client-side code minimal. However, JSON is limited in the types of values it can serialize and deserialize. Typed API can use devalue
to serialize and deserialize more complex objects.
To use devalue
instead of JSON, set the serialization
option to "devalue"
in the astro.config.js
file:
// astro.config.js
import { defineConfig } from "astro/config"
import typedApi from "astro-typed-api"
export default defineConfig({
integrations: [typedApi({ serialization: "devalue" })],
})
Reference
Modules
astro-typed-api
: The integration to be added toastro.config.js
.astro-typed-api/client
: The client-side API.api
: The client-side API object.- ... and the error constructors from
astro-typed-api/errors/client
.
astro-typed-api/server
: The server-side functions and values.defineApiRoute()
: The function for defining API routes.defineEndpoint()
: An alias fordefineApiRoute()
.type TypedAPIContext
: The interface for the context object passed to the fetch handler.type TypedAPIHandler
: The interface for a fetch handler used bydefineApiRoute()
.type ZodAPIHandler
: The interface for a fetch handler that also validates the input using a zod schema.
astro-typed-api/errors/client
: The errors that may be thrown by the client-side API.InvalidUsage
: Thrown when the client-side API is used incorrectly.NetworkError
: Thrown when there's a network failure while making the request.UnusableResponse
: Thrown when the server returns a response with a non-200 status code.CustomError
: Thrown when the server returns a custom error.
astro-typed-api/errors/server
: The server-side errors.InvalidUsage
: Thrown when the server-side API route defines a schema, but the schema does not have aparse
method.ValidationFailed
: Thrown when the server-side API route defines a schema, and the data sent by a client does not match the schema.UnusableRequest
: Thrown when the request does not include expected headers or is otherwise malformed.ProcedureFailed
: Thrown when an error occurs inside fetch handler.OutputNotSerializable
: Thrown when the fetch handler returns data that cannot be serialized using JSON or devalue.
astro-typed-api/client
: The client-side API
api
: The proxy object representing your API routes
import { api } from "astro-typed-api/client"
The api
object enables "object API mapping" to your server-side API routes. It is a Proxy
object that can be indexed into to attrive at a certain endpoint. For example, api.user.posts
selects /api/user/posts
as the endpoint. This is followed by the selection of the HTTP method, and the fetch()
invocation. For example, api.user.posts.GET.fetch()
selects /api/user/posts
as the endpoint and GET
as the HTTP method, and then makes the request to that endpoint using the GET
method.
This runtime behavior is combined with type generation to provide statically analysable usage. For example, if src/api/user/posts.ts
does not export a POST
method, then any code using api.user.posts.POST
will error during type-checking.
astro-typed-api/server
: The server-side API
import {
defineApiRoute,
defineEndpoint,
type TypedAPIContext,
type TypedAPIHandler,
type ZodAPIHandler,
} from "astro-typed-api/server"
defineApiRoute()
Creates a Request -> Response
function that astro can use as an API route, while storing the paramter and return types for type-checking on the client-side. Accepts a single object that implements either the TypedAPIHandler
or ZodAPIHandler
interface.
// src/pages/api/username/availability.ts
import { defineApiRoute, TypedAPIHandler } from "astro-typed-api/server"
/**
* Checks if a username is available or already taken.
*/
const handler: TypedAPIHandler<{ name: string }, { username_available: boolean }> = {
fetch({ name }) {
if (db.query(`SELECT * FROM users WHERE username = ${name}`)) {
return { username_available: false }
}
return { username_available: true }
}
}
export const GET = defineApiRoute(handler)
Refer to Usage for detailed examples.
defineEndpoint()
An alias for defineApiRoute()
, because API Route is too many syllables, and it's the casing for it is not consistent in the ecosystem.
import { defineEndpoint } from "astro-typed-api/server"
export const GET = defineEndpoint({
fetch({ name }: { name: string }) {
if (db.query(`SELECT * FROM users WHERE username = ${name}`)) {
return { username_available: false }
}
return { username_available: true }
}
})
TypedAPIContext
The interface representing the object passed to the fetch handler as the second argument.
import { type APIContext } from "astro"
interface TypedAPIContext extends APIContext {
response: ResponseOptions
error(details: string | ErrorDetails, response?: Partial<ResponseOptions>): Response
}
interface ErrorDetails {
reason: string
message?: string
}
/**
* Custom status code and headers for the error response.
*/
interface ResponseOptions {
status: number
headers: Headers
}
The interface includes all fields from Astro's APIContext, which is also used in normal API routes and in the middleware. Additionally, it includes two fields:
response
: a mutable object that can be used to set the status code and headers of the response.error()
: a function that can be used to send a custom error message to the client. Refer to Custom Error Handling for more details.
TypedAPIHandler
A generic interface whose input and output types are automatically inferred by defineApiRoute()
.
interface TypedAPIHandler {
fetch(input: Input, context: TypedAPIContext): Promise<Output>
}
ZodAPIHandler
The interface for a fetch handler that also validates the input using a zod schema.
interface ZodAPIHandler extends TypedAPIHandler {
schema: ZodTypeAny
}
astro-typed-api/errors/client
: Client-side errors
When using the client-side API, there is a known set of errors that can occur: InvalidUsage
, NetworkError
, UnusableResponse
. Additionally, when the server intentionally wants to refuse a request, or provide a user-facing reason for failure, it can return a custom error, which becomes catchable as CustomError
.
The constructors for all of these errors are exported from astro-typed-api/client
and astro-typed-api/client/errors
.
NetworkError
Thrown when there's a network failure while making the request, such as when the browser is offline or the server is unreachable.
Error properties:
error.cause
: The underlying error thrown by the fetch API, usually an instance ofTypeError
.
Example:
import { api, NetworkError } from "astro-typed-api/client"
const data = await api.user.GET.fetch(input).catch(error => {
const isNetworkError = error instanceof NetworkError
// or
const isNetworkError = error.name === "TypedAPI.NetworkError"
if (isNetworkError) {
// show a user-friendly error message
toast.error("Could not contact the server. Is the device connected to the internet?")
// send the underlying error to a logging service
log("fetch failed", error.cause)
}
})
UnusableResponse
Thrown in two scenarios:
- When the server returns a response with a non-200 status code.
- When the response has an unexpected format (neither JSON nor devalue).
In either scanarios, the reason may be that the server ran into an an unhandled error while running the request. Alternatively. there was an intermediate server (nginx, cloudflare or other reverse proxy) that refused the request due to, for example, the user hitting a rate-limit.
Properties:
error.type
: The reason for the failure. The value may be one of the strings"not ok"
and"unknown format"
.error.cause
: The unsuccessful response object returned by the server.
Example:
import { api, UnusableResponse } from "astro-typed-api/client"
const data = await api.posts.GET.fetch(input).catch(error => {
const isUnusableResponse = error instanceof UnusableResponse
// or
const isUnusableResponse = error.name === "TypedAPI.UnusableResponse"
if (isUnusableResponse) {
if (error.cause.status === 429) {
toast.error("You've hit a rate limit. Please wait and try again later.")
}
}
}
InvalidUsage
Thrown when the library client is used incorrectly. In most cases, this runtime error has a corresponding type error preventing the invalid usage during development.
For example:
- When calling methods other than
fetch
. - When the HTTP method is missing for an
ALL
handler - When the HTTP method is not uppercase
Properties:
error.type
: The reason for the failure. The value may be one of the strings"incorrect call"
,"missing method"
, and"invalid method"
.error.message
: A developer-readable explanation of the invalid usage.
Example:
try {
// The function being called must be `fetch`
api.endpoint.GET()
// The actual method is expected to be passed as an option, but it is not provided here
api.endpoint.ALL.fetch(input)
// The method is not uppercase
api.endpoint.get.fetch(input)
} catch (error) {
if (error.name === "TypedAPI.InvalidUsage") {
console.log(error.message) // Detailed explanation of what went wrong
}
}
CustomError
Refer to Error handling.
astro-typed-api/errors/server
: Server-side errors
InvalidUsage
Thrown when the server-side API route defines a schema, but the schema does not have a parse
method. This may be the result of using a non-zod schema or a schema that is not defined in the file where the API route is defined.
Properties:
error.type
: The reason for the failure. Currently, this is always"invalid schema"
.error.schema
: The invalid schema object that was passed to thedefineApiRoute()
function in theschema
field.
ValidationFailed
Thrown when the server-side API route defines a schema, and the data sent by a client does not match the schema.
Properties:
error.cause
: TheZodError
describing the validation failure.error.input
: The deserialized non-validatable input sent by the client.
UnusableRequest
Thrown when the request does not include expected headers or is otherwise malformed.
Properties:
error.type
: The reason for the failure. The value may be one of the strings"accept header missing"
,"unsupported accept header"
,"unsupported content type"
, and"deserialization failed"
.error.request
: The request object that was passed to the fetch handler.error.deserializationError
: If the reason is"deserialization failed"
, this is the error thrown byJSON.parse()
ordevalue
.
ProcedureFailed
A wrapper error thrown when an error occurs inside fetch handler.
Properties:
error.cause
: The error thrown during the execution of the fetch handler.
Troubleshooting
For help, check out the Discussions
tab on the GitHub repo.
Contributing
This package is maintained by lilnasy independently from Astro. The integration code is located at packages/typed-api/integration.ts. You're welcome to contribute by opening a PR or submitting an issue!
Changelog
See CHANGELOG.md for a history of changes to this integration.