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.

A simple function

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.

Functions have a default implementation that works out of the box, without you having to write any extra code. For example, if we pass id in the inputs in the request to the action, the function will automatically query the database to find a product matching that id.

Obviously this example is very simple, and in practice this kind of action wouldn't need to be annotated with the @function attribute in our schema, unless you require your action to do more than just query the database to find the product by its ID.

Hooks

ℹ️

If you have an existing schema that predates the introduction of Hooks please read the Migration Guide

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.

beforeWrite

The beforeWrite hook allows you to modify the values that will be written to the database.

An example might be where we want to change the value of the title of a Product to include the product's Stock Keeping Unit (SKU):

createProduct.ts
import { CreateProduct } from "@teamkeel/sdk";
 
export default CreateProduct({
  beforeWrite: async (ctx, inputs, values) => {
    return {
      ...values,
      title: `${values.title} (${values.sku})`
    };
  }
});

Arguments

The beforeWrite hook receives three arguments, namely:

  • 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 due to be written to the database.

Return type

beforeWrite expects the hook to return the model object.

In the case of the example, the return type is expected to be Product.

afterWrite

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

An example afterWrite hook that performs some side effects might be:

createProduct.ts
import { CreateProduct, models } from "@teamkeel/sdk";
 
export default CreateProduct({
  afterWrite: async (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."
    });
  }
});

Arguments

afterWrite receives three 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 written to the database, including the id

Return type

afterWrite does not expect a value to be returned from the hook.

beforeQuery

The beforeQuery hook allows you to modify the behaviour of the default query that is executed if no beforeQuery hook has been defined.

There are two ways you can use beforeQuery:

Extending the existing query

⚠️

For update actions, we do not currently support extending the existing query, so you should follow the Replacing the query entirely section below.

By default, a query will be built up based on the where conditions passed in the inputs to the function.

For example, given the following inputs to a list action:

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

If you define a beforeQuery hook, this will look like:

listProducts.ts
import { ListProducts } from "@teamkeel/sdk";
 
export default ListProducts({
  beforeQuery: (ctx, inputs, query) => {
    // some code
  }
});

The third parameter to the hook query is an instance of our QueryBuilder class with the where constraints from the inputs already applied. Without any additional mutation of the query object, the SQL that will be generated and run against the database will look something like:

SELECT * FROM product WHERE product.title LIKE 'star wars%'

So in your beforeQuery hook above, if you were to add the following code:

listProducts.ts
import { ListProducts } from "@teamkeel/sdk";
 
export default ListProducts({
  beforeQuery: (ctx, inputs, query) => {
    return query.where({
      title: {
        endsWith: 'phantom menace'
      }
    });
  }
});

The final SQL would be:

SELECT * FROM product WHERE product.title LIKE 'star wars%' AND product.title LIKE '%phantom menace'

Replacing the query entirely

beforeQuery hooks on update actions should use this approach. The method signature for update differs slightly, as a third parameter values is passed, which contains the mutated values from any beforeWrite hooks that may be defined.

Alternatively, the return type of beforeQuery also allows you to return a Promise<T> where T is the current model. So in the case of our example schema, and implementing a list action, this would be a Promise<Product[]>.

You can use our Model API to write your own custom query implementation, although you will have to handle all of the inputs to the action yourself (as shown in the example below), as the built-in behaviour will no longer take place:

listProducts.ts
import { ListProducts, models } from "@teamkeel/sdk";
 
export default ListProducts({
  beforeQuery: async (ctx, inputs) => {
    const products = await models.product.findMany({
      where: {
        ...inputs.where,
        isPublished: true,
      }
    });
 
    return products;
  }
});

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.

An example afterQuery hook might be:

ListPreviewProducts.ts
import { ListPreviewProducts } from "@teamkeel/sdk";
 
export default ListPreviewProducts({
  afterQuery: async (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)}...`
    }))
  }
});

An example afterQuery hook that performs some custom permissions logic might look like:

ListPreviewProducts.ts
import { ListPreviewProducts, permissions } from "@teamkeel/sdk";
 
export default ListPreviewProducts({
  afterQuery: async (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;
  }
});

Arguments

afterQuery receives three 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

Return type

afterQuery expects the hook to return the model (or array of model in the case of a list action).

In the example above, the expected return type will be Promise<Product[]>.

Using the database

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

See Model API for full usage.

Custom database queries

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.

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](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object, and you can set response headers by using ctx.response.headers which is a normal Headers (opens in a new tab) 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");
});

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
});

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.