Initial commit

This commit is contained in:
2026-06-18 00:54:46 +02:00
commit 72a455c1fd
10 changed files with 555 additions and 0 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
indent_style = space
[bun.lock]
indent_size = 2
indent_style = space

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/dist
/node_modules

1
.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "apios",
"private": true,
"type": "module",
"license": "UNLICENSED",
"imports": {
"#/*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js"
}
},
"scripts": {
"watch": "pnpm exec tsc --build --watch"
},
"devDependencies": {
"@effect/language-service": "^0.86.2",
"@types/node": "^24",
"typescript": "^6.0.3"
}
}

78
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,78 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
effect:
specifier: ^3.21.2
version: 3.21.2
devDependencies:
'@effect/language-service':
specifier: ^0.86.2
version: 0.86.2
'@types/node':
specifier: ^24
version: 24.12.4
typescript:
specifier: ^6.0.3
version: 6.0.3
packages:
'@effect/language-service@0.86.2':
resolution: {integrity: sha512-SaPln+8srOqDJDUwNTDmP5e+IYpEDr9+1epGznnsLqu8xvo6VnxyWARdeLpqvZJlb0Pgy9ca7ppqvvdWbHPXAg==}
hasBin: true
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@types/node@24.12.4':
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
effect@3.21.2:
resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==}
fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@effect/language-service@0.86.2': {}
'@standard-schema/spec@1.1.0': {}
'@types/node@24.12.4':
dependencies:
undici-types: 7.16.0
effect@3.21.2:
dependencies:
'@standard-schema/spec': 1.1.0
fast-check: 3.23.2
fast-check@3.23.2:
dependencies:
pure-rand: 6.1.0
pure-rand@6.1.0: {}
typescript@6.0.3: {}
undici-types@7.16.0: {}

145
src/Result.ts Normal file
View File

@@ -0,0 +1,145 @@
type Covariant<A> = (_: never) => A;
type Destruct<A, E> =
| { success: A, failure: null }
| { success: null, failure: E }
;
const identity = <A>(_: A): A => _;
const throwUnwrapError = <E>(_: E): never => { throw new UnwrapError(_); }
const TypeId = Symbol("Result");
type TypeId = typeof TypeId;
interface ResultBase<out A, out E> {
readonly [TypeId]: {
readonly _A: Covariant<A>,
readonly _E: Covariant<E>,
};
isFailure<A, E>(): this is Failure<A, E>;
isSuccess<A, E>(): this is Success<A, E>;
unwrap<E1 = never>(onFailure?: (failure: E) => E1): A | E1;
destruct(): Destruct<A, E>;
map<A1, E1 = E>(
onSuccess: (success: A) => A1,
onFailure?: (failure: E) => E1,
): Result<A1, E1>;
flatMap<A1, E1, A2 = never, E2 = E>(
onSuccess: (success: A) => Result<A1, E1>,
onFailure?: (failure: E) => Result<A2, E2>,
): Result<A1 | A2, E1 | E2>;
}
const CommonProto = Object.freeze({
[TypeId]: Object.freeze({
_A: (_: never) => _,
_E: (_: never) => _,
}),
isFailure<A, E>(this: Result<A, E>): this is Failure<A, E> { return this._tag == "Failure"; },
isSuccess<A, E>(this: Result<A, E>): this is Success<A, E> { return this._tag == "Success"; },
unwrap<A, E, E1 = never>(
this: Result<A, E>,
onFailure: (failure: E) => E1 = throwUnwrapError,
): A | E1 {
switch (this._tag) {
case "Failure":
return onFailure(this.failure);
case "Success":
return this.success;
}
},
destruct<A, E>(this: Result<A, E>): Destruct<A, E> {
switch (this._tag) {
case "Failure":
return { failure: this.failure, success: null };
case "Success":
return { failure: null, success: this.success };
}
},
map<A, E, A1, E1 = E>(
this: Result<A, E>,
onSuccess: (success: A) => A1,
onFailure: (failure: E) => E1 = identity as (_: E) => E1,
): Result<A1, E1> {
switch (this._tag) {
case "Failure":
return Result.failure(onFailure(this.failure));
case "Success":
return Result.success(onSuccess(this.success));
}
},
flatMap<A, E, A1, E1, A2 = never, E2 = E>(
this: Result<A, E>,
onSuccess: (success: A) => Result<A1, E1>,
onFailure: (failure: E) => Result<A2, E2> = Result.failure as (_: E) => Result<A2, E2>,
): Result<A1 | A2, E1 | E2> {
switch (this._tag) {
case "Failure":
return onFailure(this.failure);
case "Success":
return onSuccess(this.success);
}
},
});
export interface Failure<out A, out E> extends ResultBase<A, E> {
readonly _tag: "Failure";
readonly failure: E;
}
const FailureProto = Object.freeze(Object.assign(
Object.create(CommonProto),
{
_tag: "Failure",
},
));
export interface Success<out A, out E> extends ResultBase<A, E> {
readonly _tag: "Success";
readonly success: A;
}
const SuccessProto = Object.freeze(Object.assign(
Object.create(CommonProto),
{
_tag: "Success",
},
));
export type Result<A, E = never> = Failure<A, E> | Success<A, E>;
export const Result = Object.freeze({
isResult(u: unknown): u is Result<unknown, unknown> {
return typeof u === "object" && u !== null && TypeId in u;
},
failure<E>(failure: E): Failure<never, E> {
return Object.freeze(Object.create(FailureProto, {
failure: { value: failure },
}));
},
success<A>(success: A): Success<A, never> {
return Object.freeze(Object.create(SuccessProto, {
success: { value: success },
}));
},
});
export class UnwrapError<out E> extends Error {
readonly failure: E;
constructor(failure: E) {
super();
this.failure = failure;
}
}

76
src/StandardSchemaV1.ts Normal file
View File

@@ -0,0 +1,76 @@
/** The Standard Schema interface. */
export interface StandardSchemaV1<Input = unknown, Output = Input> {
/** The Standard Schema properties. */
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
}
export declare namespace StandardSchemaV1 {
/** The Standard Schema properties interface. */
export interface Props<Input = unknown, Output = Input> {
/** The version number of the standard. */
readonly version: 1;
/** The vendor name of the schema library. */
readonly vendor: string;
/** Validates unknown input values. */
readonly validate: (
value: unknown,
options?: StandardSchemaV1.Options | undefined
) => Result<Output> | Promise<Result<Output>>;
/** Inferred types associated with the schema. */
readonly types?: Types<Input, Output> | undefined;
}
/** The result interface of the validate function. */
export type Result<Output> = SuccessResult<Output> | FailureResult;
/** The result interface if validation succeeds. */
export interface SuccessResult<Output> {
/** The typed output value. */
readonly value: Output;
/** A falsy value for `issues` indicates success. */
readonly issues?: undefined;
}
export interface Options {
/** Explicit support for additional vendor-specific parameters, if needed. */
readonly libraryOptions?: Record<string, unknown> | undefined;
}
/** The result interface if validation fails. */
export interface FailureResult {
/** The issues of failed validation. */
readonly issues: ReadonlyArray<Issue>;
}
/** The issue interface of the failure output. */
export interface Issue {
/** The error message of the issue. */
readonly message: string;
/** The path of the issue, if any. */
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
}
/** The path segment interface of the issue. */
export interface PathSegment {
/** The key representing a path segment. */
readonly key: PropertyKey;
}
/** The Standard Schema types interface. */
export interface Types<Input = unknown, Output = Input> {
/** The input type of the schema. */
readonly input: Input;
/** The output type of the schema. */
readonly output: Output;
}
/** Infers the input type of a Standard Schema. */
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
Schema['~standard']['types']
>['input'];
/** Infers the output type of a Standard Schema. */
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
Schema['~standard']['types']
>['output'];
}

53
src/index.test.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineApi } from "./index.js";
import type { StandardSchemaV1 } from "./StandardSchemaV1.js";
interface User {
readonly userId: string;
readonly username: string;
}
interface UserCreateInfo {
readonly username: string;
}
declare const UserSchema: StandardSchemaV1<User>;
declare const UsersSchema: StandardSchemaV1<readonly User[]>;
declare const UserCreateInfoSchema: StandardSchemaV1<UserCreateInfo>;
const fetchUsers = defineApi({
url: "https://apios.invalid/api/v1/users/",
method: "GET",
responseBody: UsersSchema,
});
const createUser = defineApi({
url: "https://apios.invalid/api/v1/users/",
method: "POST",
requestBody: UserCreateInfoSchema,
responseBody: UserSchema,
credentials: "include",
});
const resultA = (await createUser({ username: "admin" })).unwrap();
const resultB = await fetchUsers();
{
const { success: users, failure } = resultB.destruct();
if (failure !== null) {
console.error("Failed to fetch users");
} else {
console.log("Users:", users);
}
}
// --- OR ---
if (resultB.isFailure()) {
console.error("Failed to fetch users");
} else {
console.log("Users:", resultB.success);
}

116
src/index.ts Normal file
View File

@@ -0,0 +1,116 @@
import { Result } from "./Result.js";
import type { StandardSchemaV1 } from "./StandardSchemaV1.js";
type RequestBody<Args extends ArgsBase> = Args["requestBody"] extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<Args["requestBody"]> : void;
type ResponseBody<Args extends ArgsBase> = Args["responseBody"] extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<Args["responseBody"]> : void;
type ErrorBody<Args extends ArgsBase> = Args["errorBody"] extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<Args["errorBody"]> : void;
type Api<Args extends ArgsBase> = (requestBody: RequestBody<Args>, signal?: AbortSignal) => Promise<Result<ResponseBody<Args>, ErrorBody<Args>>>;
interface ArgsBase {
readonly url: string;
readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | (string & {});
readonly requestBody?: StandardSchemaV1<any> | undefined;
readonly responseBody?: StandardSchemaV1<any> | undefined;
readonly errorBody?: StandardSchemaV1<any> | undefined;
readonly cache?: RequestInit["cache"];
readonly credentials?: RequestInit["credentials"];
readonly headers?: RequestInit["headers"];
}
export const defineApi = <const Args extends ArgsBase>({
url,
method,
requestBody: requestBodySchema,
responseBody: responseBodySchema,
errorBody: errorBodySchema,
cache,
credentials,
headers: headersInit,
}: Args): Api<Args> => {
const headers = new Headers(headersInit);
if (requestBodySchema !== undefined) {
headers.set("Content-Type", "application/json");
}
const init: RequestInit = {
method,
headers,
...(cache !== undefined ? { cache } : null),
...(credentials !== undefined ? { credentials } : null),
};
return async (requestBody, signal) => {
const body = requestBodySchema !== undefined ? JSON.stringify(requestBody) : null;
const response = await fetch(url, {
...init,
body,
...(signal !== undefined ? { signal } : null),
});
if (response.ok) {
if (responseBodySchema !== undefined) {
const json = await response.json();
const validateResult = await responseBodySchema["~standard"].validate(json);
if (validateResult.issues !== undefined) {
throw new ValidationError({
schema: responseBodySchema,
input: json,
issues: validateResult.issues,
});
} else {
return Result.success(validateResult.value);
}
} else {
return Result.success(undefined);
}
} else {
if (errorBodySchema !== undefined) {
const json = await response.json();
const validateResult = await errorBodySchema["~standard"].validate(json);
if (validateResult.issues !== undefined) {
throw new ValidationError({
schema: errorBodySchema,
input: json,
issues: validateResult.issues,
});
} else {
return Result.failure(validateResult.value);
}
} else {
return Result.failure(undefined);
}
}
};
};
export namespace ValidationError {
export interface Props {
readonly schema: StandardSchemaV1<any>;
readonly input: unknown;
readonly issues: readonly StandardSchemaV1.Issue[];
}
}
export class ValidationError extends Error {
readonly schema: StandardSchemaV1;
readonly input: unknown;
readonly issues: readonly StandardSchemaV1.Issue[];
constructor({
schema,
input,
issues,
}: ValidationError.Props) {
super();
this.schema = schema;
this.input = input;
this.issues = issues;
}
}

44
tsconfig.json Normal file
View File

@@ -0,0 +1,44 @@
{
"compilerOptions": {
"incremental": true,
"composite": true,
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": ".tsbuildinfo",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"types": ["node"],
"target": "ESNext",
"lib": ["ESNext"],
"jsx": "react-jsx",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"removeComments": true,
"newLine": "lf",
"strict": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noErrorTruncation": true,
"skipLibCheck": true,
"plugins": [
{ "name": "@effect/language-service" },
],
},
"include": ["./src"],
}