Skip to content

Commit

Permalink
Add support for custom constraints
Browse files Browse the repository at this point in the history
  • Loading branch information
ezzatron committed Mar 25, 2024
1 parent c9ebb24 commit 533e665
Show file tree
Hide file tree
Showing 27 changed files with 1,674 additions and 76 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ The format is based on [Keep a Changelog], and this project adheres to

## Unreleased

### Added

- Custom constraints can now be defined for `bigInteger`, `binary`, `boolean`,
`duration`, `enumeration`, `integer`, `networkPortNumber`, `number`, `string`,
and `url` declarations.

## [v0.9.1] - 2024-03-25

[v0.9.1]: https://github.com/ezzatron/austenite/releases/tag/v0.9.1
Expand Down
251 changes: 251 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@ export const earthAtomCount = bigInteger(
],
},
);

// constraints
export const earthAtomCount = bigInteger(
"EARTH_ATOM_COUNT",
"number of atoms on earth",
{
constraints: [
{
description: "must be a multiple of 1000",
constrain: (v) => v % 1_000n === 0n || "must be a multiple of 1000",
},
],
examples: [
{ value: 5_972_200_000_000_000_000_000_000n, label: "5.9722 septillion" },
],
},
);
```

### `binary`
Expand Down Expand Up @@ -183,6 +200,23 @@ export const sessionKey = binary("SESSION_KEY", "session token signing key", {
},
],
});

// constraints
export const sessionKey = binary("SESSION_KEY", "session token signing key", {
constraints: [
{
description: "must be 128 or 256 bits",
constrain: (v) =>
[16, 32].includes(v.length) || "decoded length must be 16 or 32",
},
],
examples: [
{
value: Buffer.from("SUPER_SECRET_256_BIT_SIGNING_KEY", "utf-8"),
label: "256-bit key",
},
],
});
```

### `boolean`
Expand Down Expand Up @@ -226,6 +260,24 @@ export const isDebug = boolean(
],
},
);

// constraints
export const isDebug = boolean(
"DEBUG",
"enable or disable debugging features",
{
constraints: [
{
description: "must not be enabled on Windows",
constrain: (v) =>
!v ||
process.platform !== "win32" ||
"must not be enabled on Windows",
},
],
examples: [{ value: false, label: "disabled" }],
},
);
```

### `duration`
Expand Down Expand Up @@ -267,6 +319,23 @@ export const grpcTimeout = duration("GRPC_TIMEOUT", "gRPC request timeout", {
},
],
});

// constraints
export const grpcTimeout = duration("GRPC_TIMEOUT", "gRPC request timeout", {
constraints: [
{
description: "must be a multiple of 100 milliseconds",
constrain: (v) =>
v.milliseconds % 100 === 0 || "must be a multiple of 100 milliseconds",
},
],
examples: [
{
value: Temporal.Duration.from({ milliseconds: 100 }),
label: "100 milliseconds",
},
],
});
```

### `enumeration`
Expand Down Expand Up @@ -326,6 +395,27 @@ export const logLevel = enumeration(
],
},
);

// constraints
export const logLevel = enumeration(
"LOG_LEVEL",
"the minimum log level to record",
members,
{
constraints: [
{
description: "must not be debug on Windows",
constrain: (v) =>
v !== "debug" ||
process.platform !== "win32" ||
"must not be debug on Windows",
},
],
examples: [
{ value: "error", label: "if you only want to see when things go wrong" },
],
},
);
```

### `integer`
Expand Down Expand Up @@ -359,6 +449,17 @@ export const weight = integer("WEIGHT", "weighting for this node", {
{ value: 1000, as: "1e3", label: "highest weight" },
],
});

// constraints
export const weight = integer("WEIGHT", "weighting for this node", {
constraints: [
{
description: "must be a multiple of 10",
constrain: (v) => v % 10 === 0 || "must be a multiple of 10",
},
],
examples: [{ value: 100, label: "100" }],
});
```

### `kubernetesAddress`
Expand Down Expand Up @@ -437,6 +538,21 @@ export const port = networkPortNumber(
],
},
);

// constraints
export const port = networkPortNumber(
"PORT",
"listen port for the HTTP server",
{
constraints: [
{
description: "must not be disallowed",
constrain: (v) => ![1337, 31337].includes(v) || "not allowed",
},
],
examples: [{ value: 8080, label: "standard" }],
},
);
```

### `number`
Expand Down Expand Up @@ -482,6 +598,21 @@ export const sampleRatio = number(
],
},
);

// constraints
export const sampleRatio = number(
"SAMPLE_RATIO",
"ratio of requests to sample",
{
constraints: [
{
description: "must be a multiple of 0.01",
constrain: (v) => v % 0.01 === 0 || "must be a multiple of 0.01",
},
],
examples: [{ value: 0.01, label: "1%" }],
},
);
```

### `string`
Expand Down Expand Up @@ -547,6 +678,27 @@ export const readDsn = string(
],
},
);

// constraints
export const readDsn = string(
"READ_DSN",
"database connection string for read-models",
{
constraints: [
{
description: "must not contain a password",
constrain: (v) =>
!v.includes("password") || "must not contain a password",
},
],
examples: [
{
value: "host=localhost dbname=readmodels user=projector",
label: "local",
},
],
},
);
```

### `url`
Expand Down Expand Up @@ -592,6 +744,105 @@ export const cdnUrl = url("CDN_URL", "CDN to use when serving static assets", {
},
],
});

// constraints
export const cdnUrl = url("CDN_URL", "CDN to use when serving static assets", {
constraints: [
{
description: "must not be a local URL",
constrain: (v) =>
!v.hostname.endsWith(".local") || "must not be a local URL",
},
],
examples: [
{
value: new URL("https://host.example.org/path/to/resource"),
label: "absolute",
},
],
});
```

### Constraints

You can specify custom constraints in a few ways.

To make a constraint fail, you can either throw an error or return an error
message string.

To make a constraint pass, don't throw, and don't return anything, or return
`undefined` if you prefer to be explicit. You can also return `true` to make
your constraint pass, if you want to use the `||` operator for a more compact
expression.

```ts
// exception-based constraint

import { string } from "austenite";

export const readDsn = string(
"READ_DSN",
"database connection string for read-models",
{
constraints: [
{
description: "must not contain a password",
constrain: (v) => {
// pass by not throwing
if (!v.includes("password")) return;

// fail by throwing an error
throw new Error("must not contain a password");
},
},
],
},
);
```

```ts
// return-based constraint

import { string } from "austenite";

export const readDsn = string(
"READ_DSN",
"database connection string for read-models",
{
constraints: [
{
description: "must not contain a password",
constrain: (v) => {
// pass by returning undefined
if (!v.includes("password")) return undefined;

// fail by returning a string
return "must not contain a password";
},
},
],
},
);
```

```ts
// compact return-based constraint

import { string } from "austenite";

export const readDsn = string(
"READ_DSN",
"database connection string for read-models",
{
constraints: [
{
description: "must not contain a password",
constrain: (v) =>
!v.includes("password") || "must not contain a password",
},
],
},
);
```

## See also
Expand Down
6 changes: 5 additions & 1 deletion src/constraint.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { normalize } from "./error.js";
import { createConjunctionFormatter } from "./list.js";

export type DeclarationConstraintOptions<T> = {
readonly constraints?: ExtrinsicConstraint<T>[];
};

export type Constraint<T> = IntrinsicConstraint<T> | ExtrinsicConstraint<T>;

export type IntrinsicConstraint<T> = {
Expand All @@ -12,7 +16,7 @@ export type ExtrinsicConstraint<T> = {
readonly constrain: Constrain<T>;
};

export type Constrain<T> = (v: T) => string | undefined | void;
export type Constrain<T> = (v: T) => string | undefined | void | true;

export function applyConstraints<T>(
constraints: Constraint<T>[],
Expand Down
13 changes: 11 additions & 2 deletions src/declaration/big-integer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
Constraint,
DeclarationConstraintOptions,
} from "../constraint.js";
import {
createRangeConstraint,
hasBigintRangeConstraint,
Expand All @@ -21,6 +25,7 @@ import { resolve } from "../maybe.js";
import { ScalarSchema, createScalar, toString } from "../schema.js";

export type Options = DeclarationOptions<bigint> &
DeclarationConstraintOptions<bigint> &
DeclarationExampleOptions<bigint> &
Partial<RangeConstraintSpec<bigint>>;

Expand Down Expand Up @@ -59,7 +64,8 @@ function createSchema(name: string, options: Options): ScalarSchema<bigint> {
}
}

const constraints = [];
const { constraints: customConstraints = [] } = options;
const constraints: Constraint<bigint>[] = [];

try {
if (hasBigintRangeConstraint(options)) {
Expand All @@ -69,7 +75,10 @@ function createSchema(name: string, options: Options): ScalarSchema<bigint> {
throw new SpecError(name, normalize(error));
}

return createScalar("big integer", toString, unmarshal, constraints);
return createScalar("big integer", toString, unmarshal, [
...constraints,
...customConstraints,
]);
}

function buildExamples(): Example<bigint>[] {
Expand Down
Loading

0 comments on commit 533e665

Please sign in to comment.