@jaypie/vocabulary
v0.1.7
Published
Jaypie standard application component vocabulary
Readme
Jaypie Vocabulary ⛲️
Philosophies
Fabric
- Smooth, pliable
- Things that feel right should work
- Catch bad passes
Concepts
- Fluid scalar coercion, string to object conversion
- Primitives: Boolean, String, Number (scalars); Objects, Arrays (considered different)
- Undefined always available
- "Resolvable" as scalar or function with potential promise
- Treat native types and their string representation equally (
type: Stringortype: "string")
Coercion
Arrays
- Non-arrays become arrays of that value
- Arrays of a single value become that value
- Multi-value arrays throw bad request
Objects
- Scalars become { value: Boolean | Number | String }
- Arrays become { value: [] }
- Objects with a value attribute attempt coercion with that value
- Objects without a value throw bad request
Scalars
- String
""becomesundefined - String
"true"becomestrueor1 - String
"false"becomesfalseor0 - Strings that parse to numbers use those numeric values to convert to number or boolean
- Strings that parse to
NaNthrow bad request - Boolean
truebecomes "true" or1 - Boolean
falsebecomes "false" or0 - Numbers to String are trivial
- Numbers convert to
truewhen positive - Numbers convert to
falsewhen zero or negative
Entity Model
{
"model": "job",
"type": "command",
"job": "evaluation",
"plan": "hello"
}Service Handler
import { serviceHandler } from "@jaypie/vocabulary";
const divisionHandler = serviceHandler({
alias: "division",
description: "Divides two numbers",
input: {
numerator: {
default: 12,
description: "Number 'on top', which is to be divided",
type: Number,
},
denominator: {
default: 3,
description: "Number 'on bottom', how many ways to split the value",
type: Number,
validate: (value) => value !== 0,
}
},
service: ({ numerator, denominator }) => (numerator / denominator),
});
await divisionHandler(); // =4
await divisionHandler({ numerator: 24 }); // =8
await divisionHandler({ numerator: 24, denominator: 2 }); // =12
await divisionHandler({ numerator: "14", denominator: "7" }); // =2
await divisionHandler({ numerator: 1, denominator: 0 }); // throws BadRequestError(); does not validate
await divisionHandler({ numerator: 1, denominator: "0" }); // throws BadRequestError(); does not validate
await divisionHandler('{ "numerator": "18" }'); // =3; String parses as JSON
await divisionHandler({ numerator: "ONE" }); // throws BadRequestError(); cannot coerce NaN to Number
await divisionHandler({ denominator: "TWO" }); // throws BadRequestError(); cannot coerce NaN to Number
await divisionHandler(12, 2); // throws BadRequestError(); future argument coercion may allowService Handler builds a function that initiates a "controller" step that:
- Parses the input if it is a string to object
- Coerces each input field to its type
- Calls the validation function or regular expression or checks the array
- The validation function may return false or throw
- A regular expression should be used as a matcher
- An array should validate if any scalar matches when coerced to the same type, any regular expression matches, or any function returns true (pocket throws in this case)
- Calls the service function and returns the response (or returns the processed input if no service is provided)
- Parameters are assumed required unless (a) they have a default or (b) they are
required: false
Handler Properties
The returned handler exposes config properties directly for introspection, useful for building CLI adapters and documentation:
const handler = serviceHandler({
alias: "division",
description: "Divides two numbers",
input: {
numerator: { type: Number, default: 12 },
denominator: { type: Number, default: 3 },
},
service: ({ numerator, denominator }) => numerator / denominator,
});
handler.alias; // "division"
handler.description; // "Divides two numbers"
handler.input; // { numerator: {...}, denominator: {...} }Validation Only (No Service)
When no service function is provided, the handler returns the coerced and validated input:
const validateUser = serviceHandler({
input: {
age: { type: Number, validate: (v) => v >= 18 },
email: { type: [/^[^@]+@[^@]+\.[^@]+$/] },
role: { default: "user", type: ["admin", "user", "guest"] },
},
// no service - returns processed input
});
await validateUser({ age: "25", email: "[email protected]" });
// → { age: 25, email: "[email protected]", role: "user" }
await validateUser({ age: 16, email: "[email protected]" });
// throws BadRequestError - age validation failsNatural Types
Array[]same asArray[Boolean]same asArray<boolean>[Number]same asArray<number>[Object]same as[{}]same asArray<object>[String]same as[""]same asArray<string>
BooleanNumberObject{}same asObject
String""same asString
Typed Array Coercion
Typed arrays ([String], [Number], [Boolean], [Object]) coerce each element to the specified type.
coerce([1, 2, 3], [String]) // → ["1", "2", "3"]
coerce(["1", "2"], [Number]) // → [1, 2]
coerce([1, 0, -1], [Boolean]) // → [true, false, false]
coerce([1, "hello"], [Object]) // → [{ value: 1 }, { value: "hello" }]String Splitting: Strings containing commas or tabs are automatically split into arrays.
coerce("1,2,3", [Number]) // → [1, 2, 3]
coerce("a, b, c", [String]) // → ["a", "b", "c"] (whitespace trimmed)
coerce("true\tfalse", [Boolean]) // → [true, false]Priority order:
- JSON parsing:
"[1,2,3]"→[1, 2, 3] - Comma splitting:
"1,2,3"→["1", "2", "3"] - Tab splitting:
"1\t2\t3"→["1", "2", "3"] - Single element wrap:
"42"→["42"]
RegExp Type Shorthand
A bare RegExp as type coerces to String and validates against the pattern:
const handler = serviceHandler({
input: {
email: { type: /^[^@]+@[^@]+\.[^@]+$/ },
url: { type: /^https?:\/\/.+/ },
},
service: ({ email, url }) => ({ email, url }),
});
await handler({ email: "[email protected]", url: "https://example.com" }); // ✓
await handler({ email: "invalid", url: "https://example.com" }); // ✗ BadRequestErrorValidated Type Shorthand
Arrays of literals validate a value against allowed options.
String validation - array of strings and/or RegExp:
const sendMoneyHandler = serviceHandler({
input: {
amount: { type: Number },
currency: { type: ["dec", "sps"] }, // Must be "dec" or "sps"
user: { type: String },
},
service: ({ amount, currency, user }) => ({ amount, currency, user }),
});
await sendMoneyHandler({ amount: 100, currency: "dec", user: "bob" }); // ✓
await sendMoneyHandler({ amount: 100, currency: "usd", user: "bob" }); // ✗ BadRequestErrorNumber validation - array of numbers:
const taskHandler = serviceHandler({
input: {
priority: { type: [1, 2, 3, 4, 5] }, // Must be 1-5
title: { type: String },
},
service: ({ priority, title }) => ({ priority, title }),
});
await taskHandler({ priority: 1, title: "Urgent" }); // ✓
await taskHandler({ priority: 10, title: "Invalid" }); // ✗ BadRequestErrorMixed string and RegExp validation:
const handler = serviceHandler({
input: {
value: { type: [/^test-/, "special"] }, // Matches /^test-/ OR equals "special"
},
service: ({ value }) => value,
});
await handler({ value: "test-123" }); // ✓
await handler({ value: "special" }); // ✓
await handler({ value: "other" }); // ✗ BadRequestErrorCommander Adapter
The vocabulary package includes utilities for integrating service handlers with Commander.js CLIs.
registerServiceCommand
The simplest way to register a service handler as a Commander command:
import { Command } from "commander";
import { serviceHandler } from "@jaypie/vocabulary";
import { registerServiceCommand } from "@jaypie/vocabulary/commander";
const handler = serviceHandler({
alias: "greet",
description: "Greet a user",
input: {
userName: { type: String, flag: "user", letter: "u" },
loud: { type: Boolean, letter: "l", default: false },
},
service: ({ loud, userName }) => {
const greeting = `Hello, ${userName}!`;
return loud ? greeting.toUpperCase() : greeting;
},
});
const program = new Command();
registerServiceCommand({ handler, program });
program.parse();
// Usage: greet --user Alice -lConfiguration options:
handler- The service handler to registerprogram- The Commander program or commandname- Override command name (defaults to handler.alias)description- Override description (defaults to handler.description)exclude- Field names to exclude from optionsoverrides- Per-field option overrides
Input Flag and Letter Properties
Input definitions support flag and letter for Commander.js integration:
input: {
userName: {
type: String,
flag: "user", // Long flag: --user (instead of --user-name)
letter: "u", // Short flag: -u
},
verbose: {
type: Boolean,
letter: "v", // Short flag: -v
},
}
// Generates: --user <userName>, -u and --verbose, -vcreateCommanderOptions
Generates Commander.js Option objects from handler input definitions:
import { createCommanderOptions } from "@jaypie/vocabulary/commander";
const { options } = createCommanderOptions(handler.input, {
exclude: ["internalField"], // Fields to skip
overrides: {
userName: {
short: "u", // Add short flag: -u
description: "Override desc", // Override description
hidden: true, // Hide from help
},
},
});
options.forEach((opt) => program.addOption(opt));parseCommanderOptions
Converts Commander.js options back to handler input format with type coercion:
import { parseCommanderOptions } from "@jaypie/vocabulary/commander";
const input = parseCommanderOptions(program.opts(), {
input: handler.input, // For type coercion and flag mapping
exclude: ["help", "version"], // Fields to skip
});Manual Integration Example
For more control, you can use createCommanderOptions and parseCommanderOptions directly:
import { Command } from "commander";
import { serviceHandler } from "@jaypie/vocabulary";
import { createCommanderOptions, parseCommanderOptions } from "@jaypie/vocabulary/commander";
const handler = serviceHandler({
input: {
userName: { type: String, description: "User name" },
maxRetries: { type: Number, default: 3, description: "Max retries" },
verbose: { type: Boolean, description: "Verbose output" },
},
service: (input) => console.log(input),
});
const program = new Command();
// Create Commander options from handler input
const { options } = createCommanderOptions(handler.input);
options.forEach((opt) => program.addOption(opt));
program.action(async (opts) => {
// Parse Commander options back to handler input
const input = parseCommanderOptions(opts, { input: handler.input });
await handler(input);
});
program.parse();Lambda Adapter
The vocabulary package includes utilities for integrating service handlers with AWS Lambda.
lambdaServiceHandler
Wraps a service handler for use as an AWS Lambda handler with full lifecycle management:
import { serviceHandler } from "@jaypie/vocabulary";
import { lambdaServiceHandler } from "@jaypie/vocabulary/lambda";
const evaluationsHandler = serviceHandler({
alias: "evaluationsHandler",
input: {
count: { type: Number, default: 1 },
models: { type: [String], default: [] },
plan: { type: String },
},
service: ({ count, models, plan }) => ({
jobId: `job-${Date.now()}`,
plan,
}),
});
// Config object style
export const handler = lambdaServiceHandler({
handler: evaluationsHandler,
secrets: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
});
// Or handler with options style
export const handler2 = lambdaServiceHandler(evaluationsHandler, {
secrets: ["ANTHROPIC_API_KEY"],
setup: [async () => { /* initialization */ }],
teardown: [async () => { /* cleanup */ }],
});Features:
- Uses
getMessages()from@jaypie/awsto extract messages from SQS/SNS events - Calls the service handler once for each message
- Returns single response if one message, array of responses if multiple
- Uses
handler.aliasas the logging handler name (overridable vianameoption) - Supports all
lambdaHandleroptions:chaos,name,secrets,setup,teardown,throw,unavailable,validate
Before/After Comparison
Before (manual lambdaHandler wrapping):
export const handler = lambdaHandler(
async (event: unknown) => {
const messages = getMessages(event);
const results = [];
for (const message of messages) {
const result = await evaluationsHandler(message);
results.push({ status: "success", jobId: result.job.id, plan: message.plan });
}
return { status: "success", results };
},
{
name: "evaluationsHandler",
secrets: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
},
);After (using lambdaServiceHandler):
export const handler = lambdaServiceHandler({
handler: evaluationsHandler,
secrets: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
});Serialization Formats
Complete Formats
Flat Model JSON
{
"model": "job",
"type": "command",
"job": "evaluation",
"plan": "hello"
}{ errors: [] }Response Model JSON
{
data: {
"model": "entity",
"id": "identifier"
}
}{
data: [
{ "model": "entity", "id": "identifier" },
{ "model": "entity", "id": "identifier" },
]
}{
errors: []
}Lookup Shorthand
JSON
{ id: "identifier", model: "entity" }String
${model}-${id}Defined Vocabulary
Attributes
Defined Attributes
Should be treated as "strictly" defined:
- abbreviation: ""
- alias: ""
- createdAt: ""
- deletedAt: ""
- description: ""
- id: ""
- input: {}
- label: ""
- metadata: {}
- mock: {} | Boolean
- model: ""
- name: ""
- ou: ""
- output: {}
- state: {}
- type: ""
- updatedAt: ""
- xid: ""
Known Attributes
Ideally optimize for reusability:
- default: *
- input: {}
- service: ()
- value: ""
- validate: [()] | { "": () | // }
Assumed Attributes
Future intentions planned:
- authentication: () | [()]
- authorization: () | [()]
- chaos: ""
- class: ""
- context: {}
- controller: () => input | { input, context }
- env: ""
- history: {}
- locals: {}
- message: () | ""
- parameters: {}
- required: []
- role: ""
- seed: ""
- serializer: ()
- setup: [()]
- tags: [""]
- teardown: [()]
Avoidable Attributes
- body, data; prefer state
- subtype; prefer class
Models
item
- Not used directly
record
- types: text, json
- attributes: content
collection
- types: models; e.g., records, jobs
- attributes: items, subject
job
- types: api, call, command, event, schedule
- attributes: job, plan, status
Extending Vocabulary
- The easiest way to extend the base vocabulary is with namespacing
Style Guide
- Alphabetize anything
