Functions

Functions

Functions allow you to implement actions using code, but they are so much more than just "serverless functions". Your functions are deeply integrated into your app, meaning that inputs are validated, authentication and permissions are handled for you, and you have full access to your database.

We currently support TypeScript for writing functions and your function code should be located in the functions directory of your project, with each function in its own file named the same as the function in the schema. So if you have a function called doTheThing in your schema, the code for this function would be located at functions/doTheThing.ts.

Once you have described your function in your schema, you can run keel generate via the CLI to scaffold the code for your functions and put files in the right place to get you started. Then, you'd just fill in your application-specific logic in the generated file and you're good to go.

Keel supports two main types of functions:

  1. Action functions that are defined on existing actions like get, create, update, and so on. These functions modify the default behaviour of a built-in action by hooking in to the lifecycle of the action. For example, you might want to add constraints to a query before it is run, or perform custom permission logic on the returned rows after the data returns from the database.

  2. Custom functions that are unrelated to any built-in action. These functions are defined using the read or write action types and allow you to define custom inputs and responses. These functions are the closest to what you might think of as an API route in Next.js (opens in a new tab) as they are not tied to any built-in action. They are still deeply integrated into your app, however, and you can still use the built-in Keel types and APIs to interact with your database. You'd use this type of function to communicate with 3rd party APIs, do batch operations, or perform other custom logic.

Context

All functions receive a context object as their first argument. This object is very similar to the ctx object that can be used in expressions in your Keel schema. It provides type-safe access to secrets and environment variables, as well as the identity of the caller.

type Secrets = {
  // populated from the secrets defined in your keelconfig.yaml
};
 
type Environment = {
  // populated from the environment variables defined in your keelconfig.yaml
};
 
type Context = {
  secrets: Secrets;
  env: Environment;
  identity?: Identity;
  now(): Date;
};

Action functions

Functions can use the same action types as actions, for example get, list and so on. To make a built-in action a function, we apply the @function attribute. To illustrate how functions work lets implement a simple get function.

schema.keel
model Product {
  actions {
    get getProduct(id) @function
  }
}

If we then run keel generate a function file will be created for us which will look something like this.

functions/getProduct.ts
import { GetProduct } from "@teamkeel/sdk";
 
// To learn more about what you can do with hooks,
// visit https://docs.keel.so/functions
const hooks: GetProductHooks = {};
 
export default GetProduct(hooks);

The @teamkeel/sdk package is auto-generated based on your schema and contains wrapper functions for each of your schema-defined functions. These wrapper functions ensure that your code is correctly typed without having to explicitly declare types.

As you can see from this example all function hooks are optional, so even if no hooks are defined a function will still work correctly. We'll talk more about hooks in a bit, but for now they're fundamentally ways to do things around the lifecycle of the main get function which Keel still fully manages for you. For example, if we pass id in the inputs to the action, the function will query the database to find a product matching that id and return it.

Hooks

Action function hooks allow you to modify the default behaviour of a function, for example you might want to add constraints to a query, run custom permission logic on the returned rows, create related data, or perform other side effects.

Each hook is described in detail below but this table shows which hooks are called for which action types.

Hookgetlistcreateupdatedelete
beforeQuery
afterQuery
beforeWrite
afterWrite

beforeQuery

The beforeQuery hook allows you to affect which records are being acted on. If this hook is not defined then a default query is executed based on the inputs your action accepts. This hook is passed the default query and may return a modified version of it, a new query, or database record(s).

Arguments
  • ctx - a context object which contains things like the authenticated Identity, environment variables and secrets, and request headers
  • inputs - the inputs provided by the caller of your function
  • query - the default query that will be run
Example: extending the default query

By default, a query is generated based on the inputs your action accepts. For example, given the following schema:

model Film {
  fields {
    title Text
  }
  actions {
    listFilms(title) @function
  }
}

And an implementation of listFilms that looks like this:

functions/listFilms.ts
import { ListFilms } from "@teamkeel/sdk";
 
export default ListFilms({
  beforeQuery(ctx, inputs, query) {
    return query.where({
      title: {
        endsWith: "phantom menace",
      },
    });
  },
});

The listFilms action will now always filter for records whose title ends with "phantom menace". If you then called the listFilms action with the following request:

{
  "where": {
    "title": {
      "startsWith": "star wars"
    }
  }
}

You would now get back films whose title starts with "star wars" and ends with "phantom menace". This example shows how you can extend the default query for all actions that this hook is valid for.

Example: custom query

If you don't want the default query behaviour at all then you can return a new query object. To illustrate this the following schema defines a get function called latestRelease:

model Film {
  fields {
    director Text
    releaseDate Date
  }
  actions {
    get latestRelease(director) @function
  }
}

If this action wasn't marked as a @function then you would get a validation error as director is not a unique field, but as this is a function we can provide a custom implementation.

functions/latestRelease.ts
import { LatestRelease, models } from "@teamkeel/sdk";
 
export default LatestRelease({
  async beforeQuery(ctx, inputs) {
    // Find the most recent film by the provided director
    const films = await models.film.findMany({
      where: {
        director: {
          equals: inputs.director,
        },
      },
      limit: 1,
      orderBy: {
        releaseDate: 'desc',
      },
    });
 
    // If no films then return null
    // Note: get actions should return a record or null
    if (films.length) === 0 {
      return null;
    }
 
    // Return the first result
    return films[0];
  },
});

afterQuery

The afterQuery hook allows you to modify the response, perform custom permission checks, or perform side effects using the data returned from the query.

Arguments
  • ctx - a context object which contains things like the authenticated Identity, environment variables and secrets, and request headers
  • inputs - the inputs provided by the caller of your function
  • data - the data that was retrieved from the database
Example: modify the response

The following example shows how afterQuery could modify the data returned from the function.

functions/listPreviewProducts.ts
import { ListPreviewProducts } from "@teamkeel/sdk";
 
export default ListPreviewProducts({
  async afterQuery(ctx, inputs, products) {
    return products.map((p) => ({
      ...p,
      // truncate the title to 10 characters if not authenticated
      title: ctx.isAuthenticated ? p.title : `${p.title.slice(0, 10)}...`,
    }));
  },
});
Example: custom permissions check

The afterQuery hook could also be used to add a custom permissions check, for example:

functions/listProducts.ts
import { ListProducts, permissions } from "@teamkeel/sdk";
 
export default ListProducts({
  async afterQuery(ctx, inputs, products) {
    const hasUnpublishedProducts = products.some((p) => !p.isPublished);
 
    if (hasUnpublishedProducts && !ctx.isAuthenticated) {
      // deny the request if any of the products returned are not published
      // and the request is not authenticated
      // permissions.deny() will throw an error and stop execution.
      permissions.deny();
    }
 
    // otherwise, just return the products in the response.
    return products;
  },
});

beforeWrite

The beforeWrite hook allows you perform side-effects or permission checks for create, update, and delete function. For create and update functions this hook can also be used to modify the values that will be written to the database.

Arguments

The arguments to beforeWrite are slightly different depending on the action type.

create

  • ctx - a context object which contains things like the authenticated Identity, environment variables and secrets, and request headers
  • inputs - the inputs provided by the caller of your function
  • values - the values that will be written to the database

update

  • ctx - a context object which contains things like the authenticated Identity, environment variables and secrets, and request headers
  • inputs - the inputs provided by the caller of your function
  • values - the new values that will be written to the database
  • record - the existing record that is going to be updated

delete

  • ctx - a context object which contains things like the authenticated Identity, environment variables and secrets, and request headers
  • inputs - the inputs provided by the caller of your function
  • record - the record that is going to be deleted
Example: mutate write values

The following example shows how the beforeWrite hook can be used to mutate the values being written to the database. Here a summary field is computed based on the title and the first 100 characters of the description.

functions/createProduct.ts
import { CreateProduct } from "@teamkeel/sdk";
 
export default CreateProduct({
  async beforeWrite(ctx, inputs, values) {
    let desc = inputs.description;
    if (desc.length > 100) {
      desc = desc.substring(0, 97) + "...";
    }
    return {
      ...values,
      summary: `${inputs.title} - ${desc}`,
    };
  },
});
Example: update based on existing values

When using this hook in an update function the fourth argument to the hook is the existing record. The following example shows how you can use that record to update the values that will be used for the update.

functions/incrementCounter.ts
import { IncrementCounter } from "@teamkeel/sdk";
 
export default IncrementCounter({
  async beforeWrite(ctx, inputs, values, record) {
    return {
      ...values,
      count: record.count + inputs.value,
    };
  },
});

afterWrite

The afterWrite hook allows you to perform side effects after the record has been written to the database for create and update and after the record has been deleted for delete. Common use cases include creating other models and performing custom permission checks.

For create and update this hook can return a modified version of the record that was created or updated. As with afterQuery this only affects the data returned from the API, not the data in the database.

ℹ️

Although the afterWrite hook can be used to perform side-effects after a write, in many cases it will be better to use an event. The key difference is that the afterWrite hook happens as part of your action and so will affect the response time of your action, whereas an event happens asynchronously.

Arguments
  • ctx - a context object which contains things like the authenticated Identity, environment variables and secrets, and request headers
  • inputs - the inputs provided by the caller of your function
  • data - the record that that was created/updated/deleted
Example: creating additional records

The following example shows how you can create additional records in the database in an afterWrite hook.

functions/createProduct.ts
import { CreateProduct, models } from "@teamkeel/sdk";
 
export default CreateProduct({
  async afterWrite(ctx, inputs, data) {
    await models.productReviews.create({
      productId: data.id,
      rating: 10,
      content: "We love it, and that's not just because we made it.",
    });
  },
});
Example: posting an update to Slack

In a delete function the third argument to the afterWrite hook is the record that was deleted. The following example shows how you could post a message to Slack whenever a product is deleted.

functions/createProduct.ts
import { DeleteProduct, models } from "@teamkeel/sdk";
import { sendSlackMessage } from "../lib/slack";
 
export default DeleteProduct({
  async afterWrite(ctx, inputs, data) {
    await sendSlackMessage(
      "#products",
      `${data.title} (${data.id}) has been deleted!`
    );
  },
});

Named inputs

Given the following schema, we have a get function called latestRelease that takes a director input.

schema.keel
model Film {
  fields {
    director Text
    releaseDate Date
  }
  actions {
    get latestRelease(director) @function
  }
}

In this case, director is a field on the model. Sometimes, we may need to pass inputs to functions that are not fields on the model. For example, we may want to pass a yearLimit input to this get function to find the latest release within a given year for directors who have released multiple films in that year.

To do this, we can add a specific input to this function that the function will receive that is not a field on the model. This is called a named input. We can do this like so.

schema.keel
model Film {
  fields {
    director Text
    releaseDate Date
  }
  actions {
    get latestRelease(director, yearLimit: Number) @function
  }
}

Now, the hooks for this function will receive a property called yearLimit on their inputs argument that can be used for further filtering.

functions/latestRelease.ts
import { ListFilms } from "@teamkeel/sdk";
 
export default ListFilms({
  async beforeQuery(ctx, inputs, query) {
    const allFilms = await query.where({
      director: {
        endsWith: "Waititi",
      },
    });
 
    // filter the films by yearLimit
    const films = allFilms.filter((f) => f.releaseDate.getFullYear() <= inputs.yearLimit);
 
    return films;
  },
});

We use inputs.yearLimit to filter the films by the year limit provided by the caller of the function even though yearLimit is not necessarily a field on the model. This is the value of named inputs.

Custom functions

There may be cases where you want to define a function that returns custom data or needs to receive unknown data as input. For these situations, you can use the read and write action types with messages.

Custom inputs and responses

To illustrate how custom functions work we will create a batch create function. The built-in Keel action types do not support this, but it can be implemented using the write action type and messages.

schema.keel
enum Genre {
  Horror
  Romance
}
 
model Book {
  fields {
    title Text
    genre Genre
  }
  actions {
    write createBooks(CreateBooksInput) returns (CreateBooksResponse)
  }
}

Actions that use the read or write type must take a message as input and use the returns keyword to define the response message. The following example demonstrates how to define the messages we used in the createBooks action.

schema.keel
message CreateBooksInput {
  // messages can be nested
  books CreateBooksBookFields[]
}
 
message CreateBooksBookFields {
  title Text
  // messages can contain enums
  genre Genre
}
 
message CreateBooksResponse {
  // messages can contain models
  books Book[]
}

Messages are defined using the message keyword and have the same syntax as the fields block in a model definition. Message fields can be other messages, models, enums, or built-in Keel types.

💡

Message names must be UpperCamelCase and must be distinct from any model or enum name.

There is nothing really different about the code for functions that use messages, and they will still be correctly typed. The implementation for createBooks might look like this.

functions/createBooks.ts
import { CreateBooks, models } from "@teamkeel/sdk";
 
export default CreateBooks(async (ctx, input) => {
  const books = await Promise.all(
    input.books.map((fields) => {
      return models.book.create({
        title: fields.title,
        genre: fields.genre,
      });
    })
  );
 
  return {
    books,
  };
});

The Any message

The built-in message Any can be used as the input or response of a read or write function. When you use this message the inputs or return type of your function will be the TypeScript type any. The Any message is useful if you want to receive unknown or arbitrary data in your function or return dynamic data.

Permissions

For custom read or write functions you must implement any permissions logic in your code. This can be done by importing the permissions from the @teamkeel/sdk package and using the allow() and deny() methods.

functions/createBooks.ts
import { permissions } from "@teamkeel/sdk";
 
export default CustomAction(async (ctx, input) => {
  if (ctx.headers.get("X-custom-auth-header") == ctx.secrets.AUTH_KEY) {
    permissions.allow();
  } else {
    return;
  }
 
  // checking row level access
  if (item.owner != ctx.identity.id) {
    permissions.deny();
  }
});

By default, functions will return permission denied until allow() is called so deny() only needs to be called if you are explicitly denying access in your code after an allow() call.

Using the database

The @teamkeel/sdk package is generated based on your schema and contains type-safe APIs for interacting with your models. These APIs are all available on the exported models object.

See Model API for full usage.

Low-level database API

If you need more complex database operations you can use the Database API to write custom queries.

Using fetch

We deploy your functions into an environment running Node.js 18.x, which means the Fetch API is available globally.

One example of using fetch is to proxy API calls to a 3rd party service through your Keel APIs. This is often useful if the API you want to use requires an API key and you don't want to expose that to your frontend.

The following example uses the special Any message type to allow any input and any response from the doSomething function:

schema.keel
model MyThing {
  actions {
    read doSomething(Any) returns (Any)
  }
}

The function implementation calls the 3rd party API, pass the API token, and return its response.

functions/doSomething.ts
import { DoSomething } from "@teamkeel/sdk";
 
export default DoSomething(async (ctx, inputs) => {
  // make an API call to 3rd party
  const res = await fetch("https://some-cool-api.com", {
    method: "POST",
    headers: {
      // use a secret to store your API token
      "Api-Token": ctx.secrets.API_TOKEN,
    },
    body: JSON.stringify({
      some: "param",
    }),
  });
 
  // return the response as JSON
  return res.json();
});

Using Headers

You can access request headers by using ctx.headers, which is a read-only version of the Headers (opens in a new tab) object, and you can set response headers by using ctx.response.headers which is a normal Headers object.

functions/myFunction.ts
export default MyFunction((inputs, api, ctx) => {
  // read a request header
  const reqHeader = ctx.headers.get("X-My-Custom-Header");
 
  // write a response header
  ctx.response.headers.set("X-My-Other-Header", "1234");
});

HTTP Status Codes

You can set the HTTP status code of the response from your function, which is useful if you want to return a redirect response from your action.

functions/myRedirectFunction.ts
import { MyRedirectFunction } from "@teamkeel/sdk"
 
export default MyRedirectFunction((inputs, api, ctx) => {
  // do some stuff...
 
  // return a redirect
  ctx.response.headers.set("Location", "https://some.url.com/");
  ctx.response.status = 302;
  return null;
});
 

Setting the HTTP response status code will only have an affect on your JSON API endpoints, so in the above example /json/api/myRedirectFunction will return a redirect response but using myRedirectFunction via GraphQL or JSON-RPC APIs will not.

Environment Variables and Secrets

Environment variables defined in your keelconfig.yaml file will be available in your functions by using ctx.env, which is typed according to the environment variables you've defined in your config file. No more un-typed process.env 🎉

In much the same way, any secret you define in your keelconfig.yaml file will be available on ctx.secrets, which is also typed. As secrets are sensitive values they are not set as environment variables and are only accessible by using ctx.secrets.

As an example if we have the following keelconfig.yaml file

keelconfig.yaml
environment:
  default:
    - name: MY_ENV_VAR
      value: "some-value"
secrets:
	- name: MY_SECRET

We could then access these in a function like so

functions/myFunction.ts
export default MyFunction(async (ctx, inputs) => {
  ctx.env.MY_ENV_VAR; // "some-value"
  ctx.secrets.MY_SECRET; // will be decrypted secret value
 
  // TypeScript will catch this with the error:
  // ts(2339) Property 'FOO' does not exist on type 'Environment'.
  ctx.env.FOO;
 
  // TypeScript will catch this with the error:
  // ts(2339) Property 'FOO' does not exist on type 'Secrets'.
  ctx.secrets.FOO;
});