const { host, hostJson, ctx, BadRequest } = globalThis.__apiplantInternals;
export function defineFunctions(definitions) {
const manifest = [];
const handlers = {};
for (const [name, definition] of Object.entries(definitions)) {
const handler =
typeof definition === "function" ? definition : definition.handler;
if (typeof handler !== "function") {
throw new TypeError(`function \`${name}\` has no handler`);
}
const { input, output, config, ...entry } = typeof definition === "function"
? {}
: definition;
delete entry.handler;
manifest.push({
name,
...entry,
...(input ? { input_schema: jsonSchema(input) } : {}),
...(output ? { output_schema: jsonSchema(output) } : {}),
...(config ? { config_schema: jsonSchema(config) } : {}),
});
handlers[name] = input && input.__schema
? (body, context) => handler(parse(input, body), context)
: handler;
}
return { __apiplant: 1, manifest, handlers };
}
function jsonSchema(schema) {
return schema.__schema ? schema.json : schema;
}
export const db = {
query(sql, params = []) {
const request = typeof sql === "string" ? { sql, params } : sql;
const rows = hostJson("query", request);
if (!Array.isArray(rows)) {
throw new Error(
"this statement returned no rows; use `db.execute` for INSERT, UPDATE and DELETE",
);
}
return rows;
},
first(sql, params = []) {
return db.query(sql, params)[0] ?? null;
},
one(sql, params = []) {
const row = db.first(sql, params);
if (row === null) throw new Error("expected one row, found none");
return row;
},
value(sql, params = []) {
const row = db.one(sql, params);
const columns = Object.values(row);
if (columns.length !== 1) {
throw new Error(
`expected one column, found ${columns.length}: ${Object.keys(row).join(", ")}`,
);
}
return columns[0];
},
execute(sql, params = []) {
const request = typeof sql === "string" ? { sql, params } : sql;
const result = hostJson("query", request);
if (Array.isArray(result)) return result.length;
return result?.rows_affected ?? 0;
},
};
export function sql(strings, ...values) {
let text = strings[0];
for (let i = 0; i < values.length; i++) {
text += `$${i + 1}${strings[i + 1]}`;
}
return { sql: text, params: values };
}
export const cache = {
get(key) {
const reply = hostJson("cache", { op: "get", key });
return reply.hit ? reply.value : null;
},
has(key) {
return hostJson("cache", { op: "exists", key }).exists === true;
},
set(key, value, ttlSeconds) {
hostJson("cache", {
op: "set",
key,
value: value === undefined ? null : value,
...(ttlSeconds === undefined ? {} : { ttl: ttlSeconds }),
});
},
delete(key) {
return hostJson("cache", { op: "delete", key }).deleted === true;
},
increment(key, by = 1, ttlSeconds) {
return hostJson("cache", {
op: "incr",
key,
by,
...(ttlSeconds === undefined ? {} : { ttl: ttlSeconds }),
}).value;
},
ttl(key) {
return hostJson("cache", { op: "ttl", key }).ttl ?? null;
},
remember(key, ttlSeconds, compute) {
const hit = cache.get(key);
if (hit !== null) return hit;
const value = compute();
cache.set(key, value, ttlSeconds);
return value;
},
};
export const email = {
send(message) {
return hostJson("send_email", message);
},
};
export function config() {
return ctx.config();
}
export function principalId() {
return ctx.principalId();
}
export function hook() {
return ctx.hook();
}
export const log = ctx.log;
export { BadRequest };
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.name = "HttpError";
this.status = status;
this.request = status >= 400 && status < 500;
}
}
function schema(json, check) {
return { __schema: 1, json, check };
}
export const s = {
string(options = {}) {
const { minLength, maxLength, pattern, format, description } = options;
return schema(
{ type: "string", ...clean({ minLength, maxLength, pattern, format, description }) },
(value, path) => {
if (typeof value !== "string") return `${path} must be a string`;
if (minLength !== undefined && value.length < minLength) {
return `${path} must be at least ${minLength} characters`;
}
if (maxLength !== undefined && value.length > maxLength) {
return `${path} must be at most ${maxLength} characters`;
}
if (pattern !== undefined && !new RegExp(pattern).test(value)) {
return `${path} must match ${pattern}`;
}
return null;
},
);
},
number(options = {}) {
return numeric("number", options);
},
integer(options = {}) {
return numeric("integer", options);
},
boolean(options = {}) {
return schema({ type: "boolean", ...clean(options) }, (value, path) =>
typeof value === "boolean" ? null : `${path} must be true or false`,
);
},
enum(values, options = {}) {
return schema({ type: "string", enum: values, ...clean(options) }, (value, path) =>
values.includes(value)
? null
: `${path} must be one of ${values.map((v) => `"${v}"`).join(", ")}`,
);
},
array(items, options = {}) {
const { minItems, maxItems, description } = options;
return schema(
{ type: "array", items: jsonSchema(items), ...clean({ minItems, maxItems, description }) },
(value, path) => {
if (!Array.isArray(value)) return `${path} must be an array`;
if (minItems !== undefined && value.length < minItems) {
return `${path} must have at least ${minItems} items`;
}
if (maxItems !== undefined && value.length > maxItems) {
return `${path} must have at most ${maxItems} items`;
}
for (let i = 0; i < value.length; i++) {
const failure = validate(items, value[i], `${path}[${i}]`);
if (failure) return failure;
}
return null;
},
);
},
object(fields, options = {}) {
const required = Object.entries(fields)
.filter(([, field]) => !field.__optional)
.map(([name]) => name);
const properties = {};
for (const [name, field] of Object.entries(fields)) {
properties[name] = jsonSchema(field);
}
return schema(
{
type: "object",
properties,
...(required.length ? { required } : {}),
...clean(options),
},
(value, path) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return `${path} must be an object`;
}
for (const [name, field] of Object.entries(fields)) {
const present = value[name] !== undefined && value[name] !== null;
if (!present) {
if (field.__optional) continue;
return `${path === "body" ? "" : `${path}.`}${name} is required`;
}
const failure = validate(
field,
value[name],
path === "body" ? name : `${path}.${name}`,
);
if (failure) return failure;
}
return null;
},
);
},
optional(field) {
return { ...field, __optional: 1 };
},
any(options = {}) {
return schema({ ...clean(options) }, () => null);
},
};
function numeric(type, options) {
const { minimum, maximum, description } = options;
return schema(
{ type, ...clean({ minimum, maximum, description }) },
(value, path) => {
if (typeof value !== "number" || Number.isNaN(value)) {
return `${path} must be a number`;
}
if (type === "integer" && !Number.isInteger(value)) {
return `${path} must be a whole number`;
}
if (minimum !== undefined && value < minimum) return `${path} must be at least ${minimum}`;
if (maximum !== undefined && value > maximum) return `${path} must be at most ${maximum}`;
return null;
},
);
}
function clean(options) {
const out = {};
for (const [key, value] of Object.entries(options)) {
if (value !== undefined) out[key] = value;
}
return out;
}
function validate(field, value, path) {
return field.check ? field.check(value, path) : null;
}
export function parse(schemaOrJson, body) {
if (!schemaOrJson.__schema) return body;
const failure = schemaOrJson.check(body, "body");
if (failure) throw new BadRequest(failure);
return body;
}