Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ jobs:
env: {}
- package: graphile/graphile-bulk-mutations
env: {}
- package: graphile/graphile-function-bindings
env: {}
- package: graphql/orm-test
env: {}
- package: graphql/test
Expand Down
103 changes: 103 additions & 0 deletions __fixtures__/seed/compute/setup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
-- Shared fixture: compute (functions) module DDL
--
-- Creates the minimum tables needed to emulate the compute module in a
-- production Constructive database: the metaschema module registration
-- tables (function_module, function_invocation_module) plus the generated
-- compute tables (function_definitions, function_api_bindings,
-- function_invocations).
--
-- The function_invocations INSERT policy mirrors the api arm of the
-- production policy: the row is only insertable when the transaction-local
-- `jwt.claims.api_id` claim matches a binding for the invoked function.
--
-- Depends on: services/setup.sql

-- =====================================================
-- METASCHEMA MODULE REGISTRATION TABLES
-- =====================================================

CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_module (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
schema_id uuid NOT NULL,
definitions_table_name text NOT NULL DEFAULT 'function_definitions',
scope text NOT NULL DEFAULT 'app'
);

CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_invocation_module (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
schema_id uuid NOT NULL,
invocations_table_name text NOT NULL DEFAULT 'function_invocations',
scope text NOT NULL DEFAULT 'app'
);

GRANT SELECT ON metaschema_modules_public.function_module TO administrator, authenticated, anonymous;
GRANT SELECT ON metaschema_modules_public.function_invocation_module TO administrator, authenticated, anonymous;

-- =====================================================
-- COMPUTE SCHEMA + GENERATED TABLES
-- =====================================================

CREATE SCHEMA IF NOT EXISTS compute_public;
GRANT USAGE ON SCHEMA compute_public TO administrator, authenticated, anonymous;

CREATE TABLE IF NOT EXISTS compute_public.function_definitions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
name text NOT NULL,
task_identifier text NOT NULL,
description text,
payload_args jsonb
);

CREATE TABLE IF NOT EXISTS compute_public.function_api_bindings (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
function_definition_id uuid NOT NULL REFERENCES compute_public.function_definitions (id) ON DELETE CASCADE,
api_id uuid NOT NULL,
alias text NOT NULL,
config jsonb NOT NULL DEFAULT '{}',
UNIQUE (api_id, alias)
);

CREATE TABLE IF NOT EXISTS compute_public.function_invocations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
task_identifier text NOT NULL,
payload jsonb,
status text NOT NULL DEFAULT 'pending',
result jsonb,
error jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
completed_at timestamptz,
duration_ms int
);

GRANT SELECT ON compute_public.function_definitions TO administrator, authenticated, anonymous;
GRANT SELECT ON compute_public.function_api_bindings TO administrator, authenticated, anonymous;
GRANT SELECT, INSERT ON compute_public.function_invocations TO administrator, authenticated, anonymous;

-- =====================================================
-- RLS — api-arm provenance policy
-- =====================================================

ALTER TABLE compute_public.function_invocations ENABLE ROW LEVEL SECURITY;

-- INSERT is only allowed when the server-injected jwt.claims.api_id claim
-- matches a binding for the invoked function (api provenance arm).
CREATE POLICY function_invocations_api_insert ON compute_public.function_invocations
FOR INSERT TO administrator, authenticated, anonymous
WITH CHECK (
EXISTS (
SELECT 1
FROM compute_public.function_api_bindings b
JOIN compute_public.function_definitions d ON d.id = b.function_definition_id
WHERE d.task_identifier = function_invocations.task_identifier
AND b.api_id = nullif(current_setting('jwt.claims.api_id', true), '')::uuid
)
);

CREATE POLICY function_invocations_select ON compute_public.function_invocations
FOR SELECT TO administrator, authenticated, anonymous
USING (true);
132 changes: 132 additions & 0 deletions __fixtures__/seed/compute/test-data.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
-- Shared fixture: compute (functions) module test data
--
-- Registers the compute_public schema in the metaschema, provisions the
-- function/invocation modules, and seeds one function definition with two
-- API bindings on the "app" API (6c9997a4-591b-4cb3-9313-4ef45d6f134e):
-- - alias "resize" → rest enabled, payload_args-derived GraphQL input
-- - alias "graphql-only" → rest disabled (absent "rest" key)
-- - alias "send_email" → fallback JSON payload GraphQL input
-- - alias "secret_fn" → graphql disabled (must not be exposed)
-- - alias "validate_order" → JSON-Schema-derived GraphQL input (enum + required)
-- plus one binding on the "public" API (28199444-da40-40b1-8a4c-53edbf91c738)
-- that must not be exposed on the "app" API.
--
-- Depends on: services/setup.sql, services/test-data.sql, compute/setup.sql

SET session_replication_role TO replica;

-- Register compute_public in the metaschema
INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description, is_public)
VALUES (
'9dba0001-0000-4000-8000-000000000001',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'compute_public',
'compute_public',
NULL,
true
) ON CONFLICT (id) DO NOTHING;

-- Module registrations (what the compute loader resolves)
INSERT INTO metaschema_modules_public.function_module (id, database_id, schema_id, definitions_table_name, scope)
VALUES (
'9dba0002-0000-4000-8000-000000000002',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'9dba0001-0000-4000-8000-000000000001',
'function_definitions',
'app'
) ON CONFLICT (id) DO NOTHING;

INSERT INTO metaschema_modules_public.function_invocation_module (id, database_id, schema_id, invocations_table_name, scope)
VALUES (
'9dba0003-0000-4000-8000-000000000003',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'9dba0001-0000-4000-8000-000000000001',
'function_invocations',
'app'
) ON CONFLICT (id) DO NOTHING;

-- Function definitions
INSERT INTO compute_public.function_definitions (id, database_id, name, task_identifier, description, payload_args)
VALUES
(
'9dba0004-0000-4000-8000-000000000004',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'resize-image',
'app/resize_image',
'Resize an image',
'[{"name": "url", "type": "text"}, {"name": "width", "type": "int"}]'
),
(
'9dba0007-0000-4000-8000-000000000007',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'send-email',
'app/send_email',
NULL,
'[]'
),
(
'9dba0008-0000-4000-8000-000000000008',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'secret-fn',
'app/secret_fn',
NULL,
'[]'
),
(
'9dba0009-0000-4000-8000-000000000009',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'validate-order',
'app/validate_order',
NULL,
'[]'
)
ON CONFLICT (id) DO NOTHING;

-- Bindings on the "app" API
INSERT INTO compute_public.function_api_bindings (id, function_definition_id, api_id, alias, config)
VALUES
(
'9dba0005-0000-4000-8000-000000000005',
'9dba0004-0000-4000-8000-000000000004',
'6c9997a4-591b-4cb3-9313-4ef45d6f134e',
'resize',
'{"graphql": true, "rest": {"path": "/resize", "methods": ["POST"]}}'
),
(
'9dba0006-0000-4000-8000-000000000006',
'9dba0004-0000-4000-8000-000000000004',
'6c9997a4-591b-4cb3-9313-4ef45d6f134e',
'graphql-only',
'{"graphql": true}'
),
(
'9dba000a-0000-4000-8000-00000000000a',
'9dba0007-0000-4000-8000-000000000007',
'6c9997a4-591b-4cb3-9313-4ef45d6f134e',
'send_email',
'{}'
),
(
'9dba000b-0000-4000-8000-00000000000b',
'9dba0008-0000-4000-8000-000000000008',
'6c9997a4-591b-4cb3-9313-4ef45d6f134e',
'secret_fn',
'{"graphql": false}'
),
(
'9dba000c-0000-4000-8000-00000000000c',
'9dba0009-0000-4000-8000-000000000009',
'6c9997a4-591b-4cb3-9313-4ef45d6f134e',
'validate_order',
'{"schema": {"type": "object", "required": ["mode"], "properties": {"mode": {"type": "string", "enum": ["fast", "slow"]}, "count": {"type": "integer"}}}}'
),
(
'9dba000d-0000-4000-8000-00000000000d',
'9dba0004-0000-4000-8000-000000000004',
'28199444-da40-40b1-8a4c-53edbf91c738',
'other_api_fn',
'{"graphql": true}'
)
ON CONFLICT (id) DO NOTHING;

SET session_replication_role TO DEFAULT;
76 changes: 76 additions & 0 deletions graphile/graphile-function-bindings/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# graphile-function-bindings

PostGraphile v5 plugin that exposes API-bound compute functions as typed
GraphQL mutations.

At schema build (gather phase), the plugin queries `function_api_bindings`
joined to `function_definitions` for the configured `apiId` and emits one
mutation per graphql-enabled binding:

```graphql
<alias>(input: <Alias>Input!): <Alias>Payload
```

The payload returns the created invocation (`invocationId`, `status`, and the
full `FunctionInvocation` when the table is exposed in the schema).

## Binding config

One binding serves both REST and GraphQL. Per-protocol behavior lives in the
binding's `config` jsonb:

```json
{
"graphql": true,
"rest": { "path": "/resize", "methods": ["POST"] }
}
```

- Absent `graphql` key ⇒ the mutation is enabled.
- `"graphql": false` ⇒ the binding is not exposed as a GraphQL mutation.

## Input type derivation

Derivation lives in `src/derive.ts` (protocol-agnostic — intended to be the
shared source for future REST payload validation). Priority:

1. A JSON Schema on the binding config (`config.schema` /
`config.payloadSchema`): object properties become input fields
(string/number/boolean/enum/array/required mapped to GraphQL equivalents).
2. The definition's `payload_args` (`[{ "name": ..., "type": ... }]`): each
declared arg becomes a nullable scalar field.
3. Fallback: a single `payload: JSON` field passed through verbatim.

No runtime payload validation is performed — derivation shapes the input
types only.

## Resolver

Invoking a function is a plain, RLS-enforced `INSERT INTO
function_invocations` executed on the normal authenticated connection
(`withPgClient` + `pgSettings` from the Grafast context). The server layer is
responsible for pgSettings (including the `jwt.claims.api_id` provenance
claim) — the plugin never sets claims, never uses a privileged connection,
and never bypasses RLS.

## Usage

```ts
import { createFunctionBindingsPlugin } from 'graphile-function-bindings';

const preset = {
// ...
plugins: [createFunctionBindingsPlugin({ apiId })],
};
```

The GraphQL server (`graphql/server`) wires this automatically per API using
the resolved `api.apiId`.

## Schema invalidation

Bindings are loaded once per schema build. New or changed bindings appear
after the next schema rebuild — i.e. when the server's cached PostGraphile
handler for the API is invalidated (graphile-cache LISTEN/NOTIFY) or the
server restarts. TODO: emit a cache invalidation NOTIFY when
`function_api_bindings` rows change so rebuilds happen automatically.
19 changes: 19 additions & 0 deletions graphile/graphile-function-bindings/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testTimeout: 60000,
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json'
}
]
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
Loading
Loading