Skip to main content

Reusable Modules Starter

Category: Engineering & DevOps

Get this pack →

This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.

Stop copy-pasting the same database connection and the same auth check into every config. This starter shows Air Pipe reusable modules: define a block once, reference it everywhere, and change it in one place.

It ships two modules and three configs that share them:

FileKindWhat it is
app-db.ymlmodule (Database)The Postgres connection — defined once
require-auth.ymlmodule (Assert)A "require a valid JWT" check — defined once
main.ymlconfigContacts service — references both modules
companies.ymlconfigCompanies service — references the same two modules
seed.ymlconfigSeeds the DB — references the shared connection

Why modules

Without modules, every config repeats the same global.databases block and the same JWT-validation action. Change a host or tighten an auth rule and you edit N files — every edit a chance to get security subtly wrong.

With modules, the connection and the auth rule live in one revisioned place:

# Before — copy-pasted into every config
global:
databases:
main:
driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

# After — referenced by handle
global:
databases:
main: a|module::app-db|
# Before — the JWT check re-written on every protected endpoint
- name: RequireAuth
input: a|headers|
assert:
http_code_on_error: 401
error_message: "missing or invalid bearer token"
tests:
- value: authorization
is_not_null: true
is_valid_jwt: "a|var::JWT_SECRET|"

# After — referenced by handle
- name: RequireAuth
input: a|headers|
assert: a|module::require-auth|

Both main.yml and companies.yml call their connection main locally, but it resolves to one shared definition — so there is no silent connection-name collision, and changing app-db.yml updates all three configs at once.

Endpoints

MethodRouteAuthDescription
POST/api/seedCreate tables + load sample data (run first)
GET/contactsList contacts
POST/contacts/createJWTCreate a contact
GET/companiesList companies
POST/companies/createJWTCreate a company

Setup

Set two managed variables before installing:

  • DATABASE_URL — your Postgres connection string (the pgcrypto extension is enabled by /api/seed for gen_random_uuid()).
  • JWT_SECRET — a 32+ character secret used to validate tokens (HS256).

Then seed the database:

curl -X POST https://<your-airpipe-host>/api/seed
# -> { "contacts": 3, "companies": 2 }

Walkthrough

List (public):

curl https://<your-airpipe-host>/contacts
curl https://<your-airpipe-host>/companies

Create (JWT-protected). Mint an HS256 token whose secret matches JWT_SECRET (e.g. at jwt.io, payload { "sub": "1", "exp": <future> }), then send it in the Authorization header:

curl -X POST https://<your-airpipe-host>/contacts/create \
-H "Authorization: <jwt>" \
-H "Content-Type: application/json" \
-d '{ "name": "Katherine Johnson", "email": "[email protected]" }'

curl -X POST https://<your-airpipe-host>/companies/create \
-H "Authorization: <jwt>" \
-H "Content-Type: application/json" \
-d '{ "name": "Initech", "domain": "initech.example.com" }'

A missing or invalid token returns 401 missing or invalid bearer token — the message comes from the shared require-auth module, so it is identical on both services. Change it once and both update.

Make it your own

  • Swap the database — edit app-db.yml only (e.g. point it at a different host, or add pool settings). Every config that references a|module::app-db| picks up the change.
  • Tighten auth — edit require-auth.yml only (add required claims, switch to a JWKS URL, change the error). Every protected endpoint updates at once.
  • Add a service — new config, main: a|module::app-db|, reuse a|module::require-auth| on its writes. No connection block, no auth boilerplate.

Modules also cover Action, NetworkPolicy, and Schedule types — this starter focuses on the two highest-value cases: the shared connection and the shared auth check.

Configuration

app-db.yml

driver: postgres
conn_string: "a|ap_var::DATABASE_URL|"

companies.yml

name: reusable-modules-companies

docs: true

# Companies service — a SECOND, independent config that reuses the exact same two
# modules as main.yml. This is the whole point of modules: the connection and the
# auth rule are defined once (app-db.yml, require-auth.yml) and shared here by
# reference. No copy-paste, and both configs can safely call their connection
# `main` because it resolves to one shared definition.

global:
variables:
JWT_SECRET: "a|ap_var::JWT_SECRET|"

databases:
main: a|module::app-db|

interfaces:

# GET /companies — public list.
companies:
output: http
method: GET
summary: List companies
description: Public list of the most recent companies. Uses the shared app-db module.
tags: [companies]
response_example:
- id: 8a2b1c3d-7b2f-5d8b-af4b-2e3f4a5b6c7d
name: Acme Inc
domain: acme.example.com
created_at: "2026-07-21T00:00:00Z"
actions:
- name: ListCompanies
database: main
query: "SELECT id, name, domain, created_at FROM companies ORDER BY created_at DESC LIMIT 100;"

# POST /companies/create — JWT-protected via the SAME require-auth module.
companies/create:
output: http
method: POST
summary: Create a company (JWT-protected)
description: >
Creates a company. Protected by the shared require-auth module — send a valid
JWT in the `Authorization` header. Body: { "name": "...", "domain": "..." }.
tags: [companies]
request_example:
name: Globex
domain: globex.example.com
actions:
- name: RequireAuth
input: a|headers|
hide_data_on_success: true
assert: a|module::require-auth|

- name: ValidateBody
run_when_succeeded:
actions: [RequireAuth]
http_code_on_error: 401
input: a|body|
hide_data_on_success: true
assert:
http_code_on_error: 400
error_message: "name is required"
tests:
- value: name
is_not_null: true
is_not_empty: true

- name: CreateCompany
run_when_succeeded:
actions: [ValidateBody]
http_code_on_error: 400
database: main
query: |
INSERT INTO companies (name, domain)
VALUES ($1, $2)
RETURNING id, name, domain, created_at;
params:
- a|ValidateBody::name|
- a|ValidateBody::domain->default(null)|
post_transforms:
- extract_value: "[0]"

main.yml

name: reusable-modules-contacts

docs: true

# Contacts service.
#
# This config needs a database connection AND a JWT check — the same ones every
# other config in this pack needs. Rather than copy-paste a `global.databases`
# block and a JWT-validation action into every file, it references two reusable
# MODULES:
#
# a|module::app-db| -> the shared Postgres connection (see app-db.yml)
# a|module::require-auth| -> the shared bearer-token check (see require-auth.yml)
#
# Define once, reference everywhere. See companies.yml — it reuses the exact same
# two modules.

global:
variables:
# The require-auth module's `a|var::JWT_SECRET|` reads this. Each config supplies
# its own secret var, so the module carries the rule, not the secret.
JWT_SECRET: "a|ap_var::JWT_SECRET|"

databases:
# Local alias `main` -> the shared module. companies.yml also calls its
# connection `main`, but it is ONE shared definition — no silent connection-name
# collision, and no duplicated connection block.
main: a|module::app-db|

interfaces:

# GET /contacts — public list.
contacts:
output: http
method: GET
summary: List contacts
description: Public list of the most recent contacts. Uses the shared app-db module.
tags: [contacts]
response_example:
- id: 6f1e0b2c-6a1e-4c7a-9f3a-1d2e3f4a5b6c
name: Ada Lovelace
email: [email protected]
created_at: "2026-07-21T00:00:00Z"
actions:
- name: ListContacts
database: main
query: "SELECT id, name, email, created_at FROM contacts ORDER BY created_at DESC LIMIT 100;"

# POST /contacts/create — JWT-protected. Send the token in the Authorization header.
contacts/create:
output: http
method: POST
summary: Create a contact (JWT-protected)
description: >
Creates a contact. Protected by the shared require-auth module — send a valid
JWT in the `Authorization` header. Body: { "name": "...", "email": "..." }.
tags: [contacts]
request_example:
name: Grace Hopper
email: [email protected]
actions:
# Reused auth check — the module IS the middleware.
- name: RequireAuth
input: a|headers|
hide_data_on_success: true
assert: a|module::require-auth|

- name: ValidateBody
run_when_succeeded:
actions: [RequireAuth]
http_code_on_error: 401
input: a|body|
hide_data_on_success: true
assert:
http_code_on_error: 400
error_message: "name is required"
tests:
- value: name
is_not_null: true
is_not_empty: true

- name: CreateContact
run_when_succeeded:
actions: [ValidateBody]
http_code_on_error: 400
database: main
query: |
INSERT INTO contacts (name, email)
VALUES ($1, $2)
RETURNING id, name, email, created_at;
params:
- a|ValidateBody::name|
- a|ValidateBody::email->default(null)|
post_transforms:
- extract_value: "[0]"

require-auth.yml

error_message: "missing or invalid bearer token"
http_code_on_error: 401
tests:
- value: authorization
is_not_null: true
is_valid_jwt: "a|var::JWT_SECRET|"

seed.yml

name: reusable-modules-seed
description: Creates the contacts and companies tables (idempotent) and loads sample rows. Safe to re-run.

docs: true

# A THIRD config that reuses the shared app-db module — same connection, defined
# once in app-db.yml, referenced here by handle. No copy-pasted connection block.

global:
databases:
main: a|module::app-db|

interfaces:

# POST /api/seed — create tables + load sample data. Safe to re-run.
api/seed:
output: http
method: POST
summary: Seed the database
description: Idempotently creates the contacts and companies tables and loads sample rows.
tags: [setup]
actions:
- name: EnableExtensions
database: main
hide_data_on_success: true
query: "CREATE EXTENSION IF NOT EXISTS pgcrypto;"

- name: CreateContactsTable
run_when_succeeded: [EnableExtensions]
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
email text,
created_at timestamptz NOT NULL DEFAULT now()
);

- name: CreateCompaniesTable
run_when_succeeded: [CreateContactsTable]
database: main
hide_data_on_success: true
query: |
CREATE TABLE IF NOT EXISTS companies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
domain text,
created_at timestamptz NOT NULL DEFAULT now()
);

- name: TruncateTables
run_when_succeeded: [CreateCompaniesTable]
database: main
hide_data_on_success: true
query: "TRUNCATE contacts, companies RESTART IDENTITY;"

- name: InsertContacts
run_when_succeeded: [TruncateTables]
database: main
hide_data_on_success: true
query: |
INSERT INTO contacts (name, email) VALUES
('Ada Lovelace', '[email protected]'),
('Grace Hopper', '[email protected]'),
('Alan Turing', '[email protected]');

- name: InsertCompanies
run_when_succeeded: [InsertContacts]
database: main
hide_data_on_success: true
query: |
INSERT INTO companies (name, domain) VALUES
('Acme Inc', 'acme.example.com'),
('Globex', 'globex.example.com');

- name: SeedSummary
run_when_succeeded: [InsertCompanies]
database: main
query: |
SELECT
(SELECT COUNT(*) FROM contacts) AS contacts,
(SELECT COUNT(*) FROM companies) AS companies;
post_transforms:
- extract_value: "[0]"

schema.sql

-- Reusable Modules Starter — schema (Postgres).
-- Applied idempotently by seed.yml (POST /api/seed). gen_random_uuid() needs pgcrypto.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE IF NOT EXISTS contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
email text,
created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS companies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
domain text,
created_at timestamptz NOT NULL DEFAULT now()
);