HTTP Routes
Gadget allows you to run code whenever someone accesses a specific URL on your domain. The combination of a URL pattern and handler is known as a route. Gadget's routes are built on Fastify routes, so your handlers can use all of Fastify's features like body parsing, validation, and middleware.
Anatomy of a route
HTTP routes in Gadget are defined as files in the routes
folder which export a single function accepting a request
and a reply
object. The route is served at a given URL path defined by the route's filename, similar to Next.js
routes. Gadget runs the function when a request is made to the route's URL, and the function can do whatever it wants with the request
and reply
object, like rendering an HTML view, sending a JSON response, or redirecting the user based on some cookies.
Here's an example HTTP route which renders the string "world" when a request is made to /hello
:
module.exports = async (request, reply) => {reply.send("world");};
URL patterns
The URL pattern for a route in Gadget is defined by the path to the handler's source, relative to a routes/
directory. The name of the file is also prefixed with the HTTP verb it should handle. For example, if your gadget app is my-app.gadget.app
and you have a source file at routes/test/GET-data.js
, someone accessing https://my-app.gadget.app/test/data
in the browser would run the handler in that source file.
Dynamic URLs
If you have a dynamic segment in your URL, such as an id to some Gadget resource, you can capture that piece of information by prefixing with a colon (:
). For example, routes/blog/post/GET-:id
would capture id=5
when accessing /blog/post/5
. You can then access these captured segments in request.params
when writing a route handler.
You can also capture part of a path segment, as long as it's separated by a dash. For example, routes/blog/:id/GET-post-:postId
would capture id=3, postId=10
when accessing /blog/3/post-10
. If you need a colon as part of a path segment, use two colons. For example, routes/GET-blog::post.js
would match /blog:post
.
If you need more flexibility in how parts of the path are captured, you can manually register a route through a route plugin.
All JS/TS files in the routes/
directory must be prefixed with an HTTP verb for route handlers or a +
for route plugin. Any other
filename under the routes/
directory will break your app.
Root URLs
If you want to register a route for the root of your application, or a route at the root of a folder, name the file after just the HTTP verb. For example, routes/GET.js
will match requests to /
, or routes/foo/bar/POST.js
will match routes to /foo/bar
.
Available HTTP methods
Route handler functions can listen to any of the standard HTTP methods (also known as verbs) by using that HTTP method as the first part of the file name:
GET
, likeGET-list.js
POST
, likePOST-create.js
PUT
, likePUT-update.js
PATCH
, likePUT-update.js
DELETE
HEAD
OPTIONS
Route handlers can also be prefixed with ANY
to respond to any HTTP method, like ANY-data.js
. If you want to know which HTTP method was used to make a request, you can use the request.method
property.
1module.exports = async (request, reply) => {2 if (request.method == "GET") {3 reply.send("hello world");4 } else if (request.method == "POST") {5 reply.send(`updated: ${request.body}`);6 } else {7 reply.code(404).send("not found");8 }9};
Route handlers
Route handlers should export a single function that takes a request and a reply parameter.
module.exports = async (request, reply) => {reply.send("hello world");};
module.exports = async (request, reply) => {reply.send({ message: "hello world" });};
1module.exports = async (request, reply) => {2 reply3 .code(418)4 .type("text/plain; charset=utf-8")5 .send("I can't brew you any coffee because I'm a teapot!");6};
1// /routes/quotes/GET-:id.js2module.exports = async (request, reply) => {3 if (request.params.id == "1") {4 reply.send("To be or not to be");5 } else {6 reply.code(404).send(`Unknown quote id: ${request.params.id}`);7 }8};
Route options
Fastify, the node framework powering Gadget's routes, accepts a wide variety of options to change route behaviour. Route options can be passed to fastify by setting the options
property on the route handler function that you export.
1module.exports = async (request, reply) => {2 reply.send(`hello ${request.query.name}`);3};4module.exports.options = {5 // add route options like `schema`, `errorHandler`, etc here. for example, we validate the query string has a name key6 schema: {7 querystring: {8 type: "object",9 properties: {10 name: { type: "string" },11 },12 required: ["name"],13 },14 },15};
Supported Fastify route options:
schema
: an object containing the schemas for the request and response. They need to be in JSON Schema format, check fastify's docs for more info.body
: validates the body of the request if it is a POST, PUT, or PATCH method.querystring
orquery
: validates the querystring. This can be a complete JSON Schema object, with the propertytype
ofobject
andproperties
object of parameters, or simply the values of what would be contained in theproperties
object as shown below.params
: validates the params.response
: filter and generate a schema for the response, setting a schema allows us to have 10-20% more throughput.
exposeHeadRoute
: creates a siblingHEAD
route for anyGET
routes. Defaults to the value ofexposeHeadRoutes
instance option. If you want a customHEAD
handler without disabling this option, make sure to define it before theGET
route.attachValidation
: attachvalidationError
to request, if there is a schema validation error, instead of sending the error to the error handler. The default error format is the Ajv one.onRequest(request, reply, done)
: a function as soon that a request is received, it could also be an array of functions.preParsing(request, reply, done)
: a function called before parsing the request, it could also be an array of functions.preValidation(request, reply, done)
: a function called after the sharedpreValidation
hooks, useful if you need to perform authentication at route level for example, it could also be an array of functions.preHandler(request, reply, done)
: a function called just before the request handler, it could also be an array of functions.preSerialization(request, reply, payload, done)
: a function called just before the serialization, it could also be an array of functions.onSend(request, reply, payload, done)
: a function called right before a response is sent, it could also be an array of functions.onResponse(request, reply, done)
: a function called when a response has been sent, so you will not be able to send more data to the client. It could also be an array of functions.onTimeout(request, reply, done)
: a function called when a request is timed out and the HTTP socket has been hanged up.onError(request, reply, error, done)
: a function called when an Error is thrown or send to the client by the route handler.handler(request, reply)
: the function that will handle this request. The Fastify server will be bound tothis
when the handler is called. Note: using an arrow function will break the binding ofthis
.errorHandler(error, request, reply)
: a custom error handler for the scope of the request. Overrides the default error global handler, and anything set bysetErrorHandler
in plugins, for requests to the route. To access the default handler, you can accessinstance.errorHandler
. Note that this will point to fastify's defaulterrorHandler
only if a plugin hasn't overridden it already.validatorCompiler({ schema, method, url, httpPart })
: function that builds schemas for request validations. See the Validation and Serialization documentation.serializerCompiler({ { schema, method, url, httpStatus } })
: function that builds schemas for response serialization. See the Validation and Serialization documentation.schemaErrorFormatter(errors, dataVar)
: function that formats the errors from the validation compiler. See the Validation and Serialization documentation. Overrides the global schema error formatter handler, and anything set bysetSchemaErrorFormatter
, for requests to the route.bodyLimit
: prevents the default JSON body parser from parsing request bodies larger than this number of bytes. Must be an integer. You may also set this option globally when first creating the Fastify instance withfastify(options)
. Defaults to1048576
(1 MiB).logLevel
: set log level for this route.logSerializers
: set serializers to log for this route.config
: object used to store custom configuration.prefixTrailingSlash
: string used to determine how to handle passing/
as a route with a prefix.both
(default): Will register both/prefix
and/prefix/
.slash
: Will register only/prefix/
.no-slash
: Will register only/prefix
.
See Fastify's docs for exhaustive documentation on these route options.
Route context
The request parameter provides many of the same context properties that are available in code effects. In addition to all the features available in Fastify, the request
parameter will also have the following keys:
api
: A connected, authorized instance of the generated API client for the current Gadget application. See the API Reference for more details on this object's interface.applicationSession
: A record representing the current user's session, if there is one.applicationSessionID
: The ID of the record representing the current user's session, if there is one.connections
: An object containing client objects for all connections. Read the connections guide to see what each connection provides.logger
: A logger object suitable for emitting log entries viewable in Gadget's Log Viewer.config
: An object of all the environment variables created in Gadget's Environment Variables editor.currentAppUrl
: The current url for the environment. e.g.https://my-app.gadget.app
For example, we can use the request.api
object to make API calls to your application's API, and return them as JSON:
module.exports = async (request, reply) => {const records = await request.api.someModel.findMany();await reply.code(200).send({ result: records });};
We can use the request.logger
object to emit log statements about our request:
1module.exports = async (request, reply) => {2 if (request.headers["authorization"] == "Bearer secret-token") {3 request.logger.info({ ip: request.ip }, "access granted");4 await reply.code(200).send({ result: "the protected stuff" });5 } else {6 request.logger.info({ ip: request.ip }, "access denied");7 await reply.code(403).send({ error: "access denied" });8 }9};
The request.connections
object passed to each route handler contains a client object for each connection. For example, we can make an API call for a particular shopify shop:
module.exports = async (request, reply) => {// relies on this request being made from an embedded app to know what `shopify.current` isconst result = await request.connections.shopify.current.shop.get();await reply.code(200).send({ result });};
Here's a full example demonstrating use of many of the elements of the request context:
1module.exports = async (request, reply) => {2 const { api, applicationSession, connections, logger, config } = request;34 // use "applicationSession" to the get current shopId5 const shopId = applicationSession?.get("shop");67 // if there's a current in context shopify client let's continue8 if (connections.shopify.current) {9 const product = await api.shopifyProduct.findById(request.body.productId);1011 // if the product belongs to the shop then update product with description from body12 if (product.get("shop") == shopId) {13 await connections.shopify.current.product.update(request.body.productId, {14 body: request.body.productDescription,15 });1617 // update count of updated products18 const updatedProductRecord = await api.updatedProduct.findFirst({19 filter: {20 shop: {21 equals: shopId,22 },23 },24 });25 // atomically increment the number of products we've updated in our updatedProduct model26 await api.internal.updatedProduct.update(updatedProductRecord.id, {27 updatedProduct: {28 _atomics: {29 count: {30 increment: 1,31 },32 },33 },34 });3536 // notify me via sms37 const twilio = require("twilio");38 // use "config" to pass along environment variables required by Twilio39 const client = new twilio(config.accountSid, config.authToken);4041 await client.messages.create({42 body: "Product has been updated!",43 to: "+12345678901",44 from: "+12345678901",45 });4647 logger.info(48 { productId: request.body.productId, shopId },49 "a product has been updated"50 );5152 await reply.send();53 } else {54 // couldn't find the product!55 await reply.status(404).send();56 }57 } else {58 // oops not authorized59 await reply.status(401).send();60 }61};
Route plugins
Route plugins are files that customize Fastify for the surrounding routes. You add a route plugin by creating a file starting with a +
sign in a particular folder of routes, and that plugin then modifies the Fastify server for all the routes in that folder (or subfolders of it), and doesn't modify any other routes.
Route plugins can do many different things:
- Adding other Fastify plugins like
fastify-cors
,fastify-view
, ... - Decorating requests with a logged-in user for authentication
- Redirecting anonymous users to a login page
- Checking user credentials for permissions to access a set of routes
- Conditionally registering routes in loops or if statements with
server.get
, ...
Route plugins are a function that looks like a regular Fastify plugin. Plugin files should export a single function that takes a Fastify server instance.
1// in routes/+views.js2const FastifyView = require("point-of-view");3module.exports = async (server) => {4 await server.register(FastifyView, {5 engine: {6 eta: require("eta"), // an example view engine, see https://eta.js.org/7 },8 });9};
1// in routes/+redirect.js2module.exports = async (server) => {3 server.addHook("preHandler", async (request, reply) => {4 if (request.headers["secret-value"] != "12345") {5 reply.redirect("/index");6 }7 });8};
1// in routes/+custom-routes.js2module.exports = async (server) => {3 server.get("/example/:file(^d+).png", (request, reply) => {4 reply.send({ filenameWithoutExtension: request.params.file });5 });6};
What routes do route plugins affect
Route plugins are applied to all routes in the same folder as the plugin, and all subfolders of that folder. For example, if you have a plugin in routes/admin/+auth.js
, it will affect all routes in routes/admin
and all subfolders of routes/admin
, such as routes/admin/users
and routes/admin/posts
. It will not affect routes in other folders, such as routes/public
or routes/blog
.
1routes/2 +plugins.js // will affect all routes in any folder3 admin/4 +auth.js // will only affect routes in the admin folder5 GET.js6 GET-posts.js7 users/8 +customizations.js // will only affect the one route below, and nothing in the routes/admin folder9 GET.js10 posts/11 +images.js // will only affect these posts routes in routes/posts12 GET.js13 GET-:id.js
Boot plugins
Boot plugins are Fastify server customizations that affect the whole server instead of just a part. They are registered before any other route plugins or the routes themselves. Boot plugins live in the root boot/
folder. The primary use case is to register some global state, such as creating an API client fo an external service. It isn't necessary to export anything from a boot plugin, but the only export supported is a function that will take a server instance.
1// in boot/errors.js2module.exports = async (server) => {3 server.setNotFoundHandler(async (request, reply) => {4 reply.send(`couldn't find ${request.path}`);5 });6 server.setErrorHandler(async (error, request, reply) => {7 reply.send(`something failed: ${error.message}`);8 });9};
CORS configuration
Gadget applications often serve requests made from domains other than your-app.gadget.app
which makes them cross-domain and governed by browser Cross Origin Resource Sharing (CORS) rules. If you're making requests to Gadget from a Next.js app hosted on another domain, a Shopify storefront, or anywhere other than your-app.gadget.app
, you need to make sure you have configured your app to respond to CORS requests correctly.
To set up CORS headers for your app, we suggest using fastify-cors
. fastify-cors
is a high-quality plugin for handling CORS requests from the Fastify ecosystem available on npm. To start, add it to your package.json, and Run yarn
to install it:
1{2 "dependencies": {3 "fastify-cors": "6.0.3"4 // ...5 }6}
After, you can mount fastify-cors
in your application with a Route plugin file at routes/+scope.js
:
1const FastifyCors = require("fastify-cors");2module.exports = async (server) => {3 await server.register(FastifyCors, {4 // allow CORS requests from my-cool-frontend.com5 origin: ["https://my-cool-frontend.com"],6 // allow GET, POST, and PUT requests7 methods: ["GET", "POST", "PUT"],8 // other options, see here: https://www.npmjs.com/package/fastify-cors/v/6.0.39 });10};
When your application boots, this route plugin will be required, and configure fastify-cors
to send the right Access-Control-Allow-Origin
header to browsers.
Just like all route plugins, +scope.js
files that mount fastify-cors
will only affect requests to route contained in the same
folder/subfolder as the +scope.js
file. Registering fastify-cores
directly in routes/+scope.js
will affect all the routes of your
app, or registering it in routes/api/+scope.js
will only affect requests to any route in that routes/api
folder. This allows different
CORS configurations for different parts of your application.
Allowing requests from any domain
If your app is intended for any domain, you can configure fastify-cors
to allow any origin to make requests to your app with origin: true
.
1const FastifyCors = require("fastify-cors");2module.exports = async (server) => {3 await server.register(FastifyCors, {4 origin: true, // allow requests from any domain5 });6};
Fine-grained CORS configuration
If you want to avoid setting CORS headers for your entire application, you can also manually manage CORS headers using reply.header
in a route file, or add OPTIONS-*.js
routes for handling CORS preflight requests yourself.
For example, we can reply with a base set of CORS headers right in a route file:
1module.exports = async (request, reply) => {2 // set reply headers for CORS explicitly3 await reply.headers({4 "access-control-allow-origin": "*",5 "access-control-allow-methods": "POST, GET, OPTIONS",6 "access-control-allow-headers": "Content-Type",7 });8 // send the response9 reply.send("foo");10};
Or for cross-origin requests which require preflight requests, we can handle these requests explicitly by adding an OPTIONS-foo.js
route handler:
1// OPTIONS route handler for an adjacent routes/PUT-foo.js route file2module.exports = async (request, reply) => {3 // set reply headers for CORS preflight request4 await reply.headers({5 "access-control-allow-origin": "*",6 "access-control-allow-methods": "PUT, GET, OPTIONS",7 "access-control-allow-headers": "Content-Type",8 });9 // send no response to meet the preflight request spec10 reply.send("").code(200);11};
Note that if using fastify-cors
, you don't need to add manual CORS header setting or explicit OPTIONS-*.js
routes as fastify-cors
handles this for you.
Multipart requests
Gadget applications don't support requests that use Content-Type: multipart/form-data
(multipary requests) by default, but you can add multipart support using the fastify-multipart
package from npm.
If you're processing file uploads, you can use Gadget's built in support for file storage and uploading using the file field type. See the Storing Files guide for more information.
First, you need to install fastify-multipart
from npm. You can add it to your app by adding this to your package.json
:
package.jsonjson{"dependencies": {"fastify-multipart": "5.3.1"}}
Next, you need to mount fastify-multipart
into your server with a boot plugin or route plugin. A boot plugin will register it globally, and a route plugin will register it for only some routes. Let's use a boot plugin to keep things simple:
boot/multipart.jsJavaScriptconst FastifyMultipart = require("fastify-multipart");module.exports = async (server) => {await server.register(FastifyMultipart);};
With the plugin registered, you can access POSTed file data using request.file
or request.files
in your routes:
routes/POST-upload.jsJavaScript1module.exports = async (request, reply) => {2 // process a single file3 const data = await req.file();45 data.file; // the stream of file data, is a node ReadableStream object6 data.fields; // other parsed parts of the multipary request7 data.fieldname; // the name of the key the file is stored under in the incoming data8 data.filename; // the name of the file if provided9 data.encoding; // the file encoding10 data.mimetype; // the mimetype inferred from the file using the extension1112 // do something with the file1314 reply.send({ status: "ok" });15};
For more information on working with fastify-multipart
, see the fastify-multipart docs.