feat: initial boilerplate
This commit is contained in:
46
.github/workflows/publish.yml
vendored
46
.github/workflows/publish.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Deno
|
||||
uses: maximousblk/setup-deno@v2
|
||||
|
||||
- name: Setup Node.JS
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: deno install
|
||||
- run: deno task lint
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Deno
|
||||
uses: maximousblk/setup-deno@v2
|
||||
|
||||
- run: deno install
|
||||
- run: deno task test
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish package
|
||||
run: npx jsr publish
|
||||
38
.github/workflows/test.yml
vendored
38
.github/workflows/test.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Deno
|
||||
uses: maximousblk/setup-deno@v2
|
||||
|
||||
- name: Setup Node.JS
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- run: deno install
|
||||
- run: deno task lint
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Deno
|
||||
uses: maximousblk/setup-deno@v2
|
||||
|
||||
- run: deno install
|
||||
- run: deno task test
|
||||
- run: deno task test:publish
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.volumes
|
||||
node_modules
|
||||
14
.prettierrc
Normal file
14
.prettierrc
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"singleQuote": false,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.ts",
|
||||
"options": {
|
||||
"parser": "typescript"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
28
.vscode/settings.json
vendored
28
.vscode/settings.json
vendored
@@ -1,10 +1,32 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"editor.formatOnSave": true,
|
||||
"deno.lint": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": null,
|
||||
"editor.wordWrap": "off"
|
||||
},
|
||||
"eslint.options": {
|
||||
"ignorePatterns": ["**/*.md"]
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true
|
||||
},
|
||||
"vue.format.style.initialIndent": true,
|
||||
"vue.format.script.initialIndent": true
|
||||
}
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM denoland/deno:2.3.1
|
||||
ENV TZ=UTC
|
||||
ENV PORT=8370
|
||||
EXPOSE 8370
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY api/ ./api/
|
||||
COPY relay/ ./relay/
|
||||
COPY .npmrc .
|
||||
COPY deno-docker.json ./deno.json
|
||||
|
||||
RUN chown -R deno:deno /app/
|
||||
|
||||
USER deno
|
||||
|
||||
RUN deno install --allow-scripts
|
||||
|
||||
CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"]
|
||||
16
LICENSE
16
LICENSE
@@ -1,16 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright 2025 Christoffer Rødvik.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
Commercial use is permitted, provided the Software is not sold, relicensed, or distributed as a stand-alone solution, whether in original or minimally modified form.
|
||||
Use as part of a larger work, integrated product, or service is allowed.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
95
README.md
95
README.md
@@ -1,94 +1 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/1998130/229430454-ca0f2811-d874-4314-b13d-c558de8eec7e.svg" />
|
||||
</p>
|
||||
|
||||
# Relay
|
||||
|
||||
Relay is a full stack protocol for communicating between client and server. It is also built around the major HTTP methods allowing for creating public API endpoints.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Following quick start guide gives a three part setup approach for `Relay`, `RelayApi`, and `RelayClient`.
|
||||
|
||||
### Relay
|
||||
|
||||
First thing we need is a relay instance, this is where our base procedure configuration is defined. This space should be environment agnostic meaning we should be able to import our relay instance in both back end front end environments.
|
||||
|
||||
```ts
|
||||
import { Relay, rpc, route } from "@valkyr/relay";
|
||||
|
||||
export const relay = new Relay({
|
||||
user: {
|
||||
create: rpc
|
||||
.method("user:create")
|
||||
.params(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
email: z.string().check(z.email()),
|
||||
}),
|
||||
)
|
||||
.result(z.string()),
|
||||
update: route
|
||||
.put("/users/:userId")
|
||||
.params({ userId: z.uuid() })
|
||||
.body(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional()
|
||||
}),
|
||||
),
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
As we can see in the above example we are defining a new `method` procedure with an expected `params` and `result` contracts defined using zod schemas.
|
||||
|
||||
### API
|
||||
|
||||
To be able to process relay requests on our server we create a `RelayApi` instance which consumes our relay routes. We do this by retrieving the procedure from the relay and attaching a handler to it. When we define new procedure methods we get a new instance which we apply to the api, this ensures that the changes to the procedure on the server only affects the relay on the server.
|
||||
|
||||
```ts
|
||||
import { NotFoundError } from "@valkyr/relay";
|
||||
|
||||
import { relay } from "@project/relay";
|
||||
|
||||
export const api = relay.api([
|
||||
relay
|
||||
.method("user:create")
|
||||
.handle(async ({ name, email }) => {
|
||||
const user = await db.users.insert({ name, email });
|
||||
if (user === undefined) {
|
||||
return new NotFoundError();
|
||||
}
|
||||
return user.id;
|
||||
}),
|
||||
relay
|
||||
.put("/users/:userId")
|
||||
.handle(async ({ userId }, { name, email }) => {
|
||||
await db.users.update({ name, email }).where({ id: userId });
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
With the above example we now have a `method` handler for the `user:create` method, and a `put` handler for the `/users/:userId` route path.
|
||||
|
||||
### Web
|
||||
|
||||
Now that we have both our relay and api ready to recieve requests we can trigger a user creation request in our web application by creating a new `client` instance.
|
||||
|
||||
```ts
|
||||
import { HttpAdapter } from "@valkyr/relay/http";
|
||||
|
||||
import { relay } from "@project/relay";
|
||||
|
||||
const client = relay.client({
|
||||
adapter: new HttpAdapter("http://localhost:8080")
|
||||
});
|
||||
|
||||
const userId = await client.user.create({
|
||||
name: "John Doe",
|
||||
email: "john.doe@fixture.none"
|
||||
});
|
||||
|
||||
await client.user.update({ userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" });
|
||||
```
|
||||
# Boilerplate
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { RelayAdapter, RelayRESTInput } from "../libraries/adapter.ts";
|
||||
import { RelayError, UnprocessableContentError } from "../libraries/errors.ts";
|
||||
import { RelayProcedureInput, RelayProcedureResponse } from "../mod.ts";
|
||||
|
||||
export class HttpAdapter implements RelayAdapter {
|
||||
#id: number = 0;
|
||||
|
||||
constructor(readonly url: string) {}
|
||||
|
||||
async send({ method, params }: RelayProcedureInput): Promise<RelayProcedureResponse> {
|
||||
const id = this.#id++;
|
||||
const res = await fetch(this.url, {
|
||||
method: "POST",
|
||||
headers: { "x-relay-type": "rpc", "content-type": "application/json" },
|
||||
body: JSON.stringify({ relay: "1.0", method, params, id }),
|
||||
});
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType !== "application/json") {
|
||||
return {
|
||||
relay: "1.0",
|
||||
error: new UnprocessableContentError(`Invalid 'content-type' in header header, expected 'application/json', received '${contentType}'`),
|
||||
id,
|
||||
};
|
||||
}
|
||||
const json = await res.json();
|
||||
if ("error" in json) {
|
||||
return {
|
||||
relay: "1.0",
|
||||
error: RelayError.fromJSON(json.error),
|
||||
id,
|
||||
};
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async fetch({ method, url, query, body }: RelayRESTInput): Promise<any> {
|
||||
const res = await fetch(`${url}${query}`, {
|
||||
method,
|
||||
headers: { "x-relay-type": "rest", "content-type": "application/json" },
|
||||
body,
|
||||
});
|
||||
const data = await res.text();
|
||||
if (res.status >= 400) {
|
||||
throw new Error(data);
|
||||
}
|
||||
if (res.headers.get("content-type")?.includes("json")) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
9
api/config.ts
Normal file
9
api/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { config as auth } from "~libraries/auth/config.ts";
|
||||
import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts";
|
||||
|
||||
export const config = {
|
||||
name: "valkyr",
|
||||
host: getEnvironmentVariable("API_HOST", "0.0.0.0"),
|
||||
port: getEnvironmentVariable("API_PORT", toNumber, "8370"),
|
||||
...auth,
|
||||
};
|
||||
6
api/deno.json
Normal file
6
api/deno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"imports": {
|
||||
"~config": "./config.ts",
|
||||
"~libraries/": "./libraries/"
|
||||
}
|
||||
}
|
||||
28
api/libraries/auth/.keys/private
Normal file
28
api/libraries/auth/.keys/private
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCy5ZoXkKP9mZTk
|
||||
sKbQdSwspHZqyMH33Gby23+9ycNHMIww7djcWFfPRW4s7tu3SNaac6qVg9OI43+Z
|
||||
6BPXxuh4nhQ4LX5No9iVEmcWvZtKE4ghwzsoU0llT7+aKl9UYvgqU1YX4zyfiyo2
|
||||
bW0nVPasEHTyjLCVPK5BKlq+UmuyJTVcduALDnVETpUefu5Vca6tIRXsOovvAf5b
|
||||
zmcxPccaXIatR/AeipxT0YWoInn8dxD3kyFgTPXtinuBZxvp6MUeSs5IE8OJRJRP
|
||||
PEo1MQ9HFw9aYRIn9uIkbARbNZMGz77zB1+0TrPGyKOB5lLReWGMUFAJhjLrnTsY
|
||||
z19se4kNAgMBAAECgf9QkG6A6ViiHIMnUskIDeP5Xir19d9kbGwrcn0F2OXYaX+l
|
||||
Oot9w3KM6loRJx380/zk/e0Uch1MeZ2fyqQRUmAGQIzkXUm6LUWIekYQN6vZ3JlP
|
||||
YA2/M+otdd8Tpws9hFSDMUlx0SP3GAi0cE48xdBkVAT0NjZ3Jjor7Wv6GLe//Kzg
|
||||
1OVrbPAA/+RrPB+BQn5nmZFT0aLuLpyxB4f4ArHG/8DEBY49Syy7/3Ke0kfHMnhl
|
||||
5Eg5Yau89wSLqEoUSuQvNixu/5nTTQ6v1VYPVG8D1hn773SbNoY9o5vZOPRl1P0q
|
||||
9YC/qpzPJkm/A5TZLsoalIxuGTdwts+DaEeoKmECgYEA5CddLQbMNu9kYElxpSA3
|
||||
xXoTL71ZBCQsWExmJrcGe2lQhGO40lF8jE6QnEvMt0mp8Dg9n2ih4J87+2Ozb0fp
|
||||
2G2ilNeMxM7keywA/+Cwg71QyImppU0lQ5PYLv+pllfxN8FPpLBluy7rDahzphkn
|
||||
1rijqI5d4bHNG6IgD2ynteECgYEAyLs2eBWxX39Jff3OdpSVmHf7NtacbtsUf1qM
|
||||
RJSvLsiSwKn39n1+Y6ebzftxm/XD/j8FbN8XvMZMI4OrlfzP+YJaTybIbHrLzCE2
|
||||
B5E9j0GbJRhJ/D3l9FQBGdY4g5yC4mgbncXURQqqQTtKk2d+ixZSrw8iyDGN+aMJ
|
||||
ybqZoK0CgYALb6GvARk5Y7R/Uw8cPMou3tiZWv9cQsfqQSIZrLDpfLTpfeokuKrq
|
||||
iYGcI/yF725SOS91jxQWI0Upa6zx1gP1skEk/szyjIBNYD5IlSWj5NhoxOW5AG3u
|
||||
vjlm2a/RdmUD62+njKP8xvRHQftSBw7FJ4okh8ZS6suiJ/U9cK/TYQKBgFg+jTyP
|
||||
dNGhuKJN0NUqjvVfUa4S/ORzJXizStTfdIAhpvpR/nN7SfPvfDw6nQBOM+JyvCTX
|
||||
kqznlBNM0EL4yElNN/xx9UxTU4Ki2wjKngB7fAP7wJLGd3BI+c7s8R1S0etMj091
|
||||
59KOVLimoytYJTZqEuFoywatWlfzh9sKUH1lAoGBAID6mqGL3SZhh+i2/kAytfzw
|
||||
UswTQqA0CCBTzN/Eo1QozmUVTLQPj8rBchNSoiSc92y+lPIL8ePdU7imRB77i+9D
|
||||
9MSmc5u3ACACOSkwF0JCEGN+Rju4HR5wwm3h6Kvf/FQ3yvSEOKAWhqXIY95qtYTU
|
||||
j3O+iJbY32pbQsawIAkw
|
||||
-----END PRIVATE KEY-----
|
||||
9
api/libraries/auth/.keys/public
Normal file
9
api/libraries/auth/.keys/public
Normal file
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuWaF5Cj/ZmU5LCm0HUs
|
||||
LKR2asjB99xm8tt/vcnDRzCMMO3Y3FhXz0VuLO7bt0jWmnOqlYPTiON/megT18bo
|
||||
eJ4UOC1+TaPYlRJnFr2bShOIIcM7KFNJZU+/mipfVGL4KlNWF+M8n4sqNm1tJ1T2
|
||||
rBB08oywlTyuQSpavlJrsiU1XHbgCw51RE6VHn7uVXGurSEV7DqL7wH+W85nMT3H
|
||||
GlyGrUfwHoqcU9GFqCJ5/HcQ95MhYEz17Yp7gWcb6ejFHkrOSBPDiUSUTzxKNTEP
|
||||
RxcPWmESJ/biJGwEWzWTBs++8wdftE6zxsijgeZS0XlhjFBQCYYy6507GM9fbHuJ
|
||||
DQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
87
api/libraries/auth/auth.ts
Normal file
87
api/libraries/auth/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Auth, ResolvedSession } from "@valkyr/auth";
|
||||
import z from "zod";
|
||||
|
||||
import { db } from "~libraries/read-store/database.ts";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
|
||||
export const auth = new Auth(
|
||||
{
|
||||
settings: {
|
||||
algorithm: "RS256",
|
||||
privateKey: config.privateKey,
|
||||
publicKey: config.publicKey,
|
||||
issuer: "https://balto.health",
|
||||
audience: "https://balto.health",
|
||||
},
|
||||
session: z.object({
|
||||
accountId: z.string(),
|
||||
}),
|
||||
permissions: {
|
||||
admin: ["create", "read", "update", "delete"],
|
||||
organization: ["create", "read", "update", "delete"],
|
||||
consultant: ["create", "read", "update", "delete"],
|
||||
task: ["create", "update", "read", "delete"],
|
||||
} as const,
|
||||
guards: [],
|
||||
},
|
||||
{
|
||||
roles: {
|
||||
async add(role) {
|
||||
await db.collection("roles").insertOne(role);
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const role = await db.collection("roles").findOne({ id });
|
||||
if (role === null) {
|
||||
return undefined;
|
||||
}
|
||||
return role;
|
||||
},
|
||||
|
||||
async getBySession({ accountId }) {
|
||||
const account = await db.collection("accounts").findOne({ id: accountId });
|
||||
if (account === null) {
|
||||
return [];
|
||||
}
|
||||
return db
|
||||
.collection("roles")
|
||||
.find({ id: { $in: account.roles } })
|
||||
.toArray();
|
||||
},
|
||||
|
||||
async setPermissions() {
|
||||
throw new Error("MongoRolesProvider > .setPermissions is managed by Role aggregate projections");
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await db.collection("roles").deleteOne({ id });
|
||||
},
|
||||
|
||||
async assignAccount(roleId: string, accountId: string): Promise<void> {
|
||||
await db.collection("accounts").updateOne(
|
||||
{ id: accountId },
|
||||
{
|
||||
$push: {
|
||||
roles: roleId,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async removeAccount(roleId: string, accountId: string): Promise<void> {
|
||||
await db.collection("roles").updateOne(
|
||||
{ id: accountId },
|
||||
{
|
||||
$pull: {
|
||||
roles: roleId,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type Session = ResolvedSession<typeof auth>;
|
||||
export type Permissions = (typeof auth)["$permissions"];
|
||||
25
api/libraries/auth/config.ts
Normal file
25
api/libraries/auth/config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { SerializeOptions } from "cookie";
|
||||
|
||||
import { getEnvironmentVariable, toBoolean } from "~libraries/config/mod.ts";
|
||||
|
||||
export const config = {
|
||||
privateKey: getEnvironmentVariable(
|
||||
"AUTH_PRIVATE_KEY",
|
||||
await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"),
|
||||
),
|
||||
publicKey: getEnvironmentVariable(
|
||||
"AUTH_PUBLIC_KEY",
|
||||
await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"),
|
||||
),
|
||||
cookie: (maxAge: number) =>
|
||||
({
|
||||
httpOnly: true,
|
||||
secure: getEnvironmentVariable("AUTH_COOKIE_SECURE", toBoolean, "false"), // Set to true for HTTPS in production
|
||||
maxAge,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
}) satisfies SerializeOptions,
|
||||
};
|
||||
6
api/libraries/auth/mod.ts
Normal file
6
api/libraries/auth/mod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { auth } from "./auth.ts";
|
||||
|
||||
export * from "./auth.ts";
|
||||
export * from "./config.ts";
|
||||
|
||||
export type Auth = typeof auth;
|
||||
21
api/libraries/config/libraries/args.ts
Normal file
21
api/libraries/config/libraries/args.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { parseArgs } from "@std/cli";
|
||||
|
||||
import { Parser, toString } from "./parsers.ts";
|
||||
|
||||
export function getArgsVariable(key: string, fallback?: string): string;
|
||||
export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
|
||||
export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
|
||||
if (typeof parse === "string") {
|
||||
fallback = parse;
|
||||
parse = undefined;
|
||||
}
|
||||
const flags = parseArgs(Deno.args);
|
||||
const value = flags[key];
|
||||
if (value === undefined) {
|
||||
if (fallback !== undefined) {
|
||||
return parse ? parse(fallback) : fallback;
|
||||
}
|
||||
throw new Error(`Config Exception: Missing ${key} variable in arguments`);
|
||||
}
|
||||
return parse ? parse(value) : toString(value);
|
||||
}
|
||||
79
api/libraries/config/libraries/environment.ts
Normal file
79
api/libraries/config/libraries/environment.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { load } from "@std/dotenv";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Env, Parser, toServiceEnv, toString } from "./parsers.ts";
|
||||
|
||||
const env = await load();
|
||||
|
||||
/**
|
||||
* Get an environment variable and parse it to the desired type.
|
||||
*
|
||||
* @param key - Environment key to resolve.
|
||||
* @param parse - Parser function to convert the value to the desired type. Default: `string`.
|
||||
*/
|
||||
export function getEnvironmentVariable(key: string, fallback?: string): string;
|
||||
export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
|
||||
export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
|
||||
if (typeof parse === "string") {
|
||||
fallback = parse;
|
||||
parse = undefined;
|
||||
}
|
||||
const value = env[key] ?? Deno.env.get(key);
|
||||
if (value === undefined) {
|
||||
if (fallback !== undefined) {
|
||||
return parse ? parse(fallback) : fallback;
|
||||
}
|
||||
throw new Error(`Config Exception: Missing ${key} variable in configuration`);
|
||||
}
|
||||
return parse ? parse(value) : toString(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an environment variable, select value based on ENV map and parse it to the desired type. Can be used with simple primitives or objects / arrays
|
||||
*
|
||||
* @export
|
||||
* @param {{
|
||||
* key: string;
|
||||
* envFallback?: FallbackEnvMap;
|
||||
* fallback: string;
|
||||
* validation: z.ZodTypeAny,
|
||||
* }} options
|
||||
* @param {string} options.key - the name of the env variable
|
||||
* @param {object} options.envFallback - map with env specific fallbacks that will be used if none value provided
|
||||
* @param {string} options.envFallback.local - example "local" SERVICE_ENV target fallback value
|
||||
* @param {string} options.fallback - string fallback that will be used if no env variable found
|
||||
* @param {z.ZodTypeAny} options.validation - Zod validation object or validation primitive
|
||||
* @returns {z.infer<typeof validation>} - Returns the inferred type of the validation provided
|
||||
*/
|
||||
export function validateEnvVariable({
|
||||
key,
|
||||
envFallback,
|
||||
fallback,
|
||||
validation,
|
||||
}: {
|
||||
key: string;
|
||||
validation: z.ZodTypeAny;
|
||||
envFallback?: FallbackEnvMap;
|
||||
fallback?: string;
|
||||
}): z.infer<typeof validation> {
|
||||
const serviceEnv = getEnvironmentVariable("SERVICE_ENV", toServiceEnv, "local");
|
||||
const providedValue = env[key] ?? Deno.env.get(key);
|
||||
const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback;
|
||||
const toBeUsed = providedValue ?? fallbackValue;
|
||||
try {
|
||||
if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) {
|
||||
return validation.parse(JSON.parse(toBeUsed));
|
||||
}
|
||||
return validation.parse(toBeUsed);
|
||||
} catch (e) {
|
||||
throw new Deno.errors.InvalidData(`Config Exception: Missing valid ${key} variable in configuration`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
type FallbackEnvMap = Partial<Record<Env, string>> & {
|
||||
testing?: string;
|
||||
local?: string;
|
||||
stg?: string;
|
||||
demo?: string;
|
||||
prod?: string;
|
||||
};
|
||||
98
api/libraries/config/libraries/parsers.ts
Normal file
98
api/libraries/config/libraries/parsers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
const SERVICE_ENV = ["testing", "local", "stg", "demo", "prod"] as const;
|
||||
|
||||
/**
|
||||
* Convert an variable to a string.
|
||||
*
|
||||
* @param value - Value to convert.
|
||||
*/
|
||||
export function toString(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value.toString();
|
||||
}
|
||||
throw new Error(`Config Exception: Cannot convert ${value} to string`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an variable to a number.
|
||||
*
|
||||
* @param value - Value to convert.
|
||||
*/
|
||||
export function toNumber(value: unknown): number {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return parseInt(value);
|
||||
}
|
||||
throw new Error(`Config Exception: Cannot convert ${value} to number`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an variable to a boolean.
|
||||
*
|
||||
* @param value - Value to convert.
|
||||
*/
|
||||
export function toBoolean(value: unknown): boolean {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
throw new Error(`Config Exception: Cannot convert ${value} to boolean`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a variable to an array of strings.
|
||||
*
|
||||
* Expects a comma seprated, eg. foo,bar,foobar
|
||||
*
|
||||
* @param value - Value to convert.
|
||||
*/
|
||||
export function toArray(value: unknown): string[] {
|
||||
if (typeof value === "string") {
|
||||
if (value === "") {
|
||||
return [];
|
||||
}
|
||||
return value.split(",");
|
||||
}
|
||||
throw new Error(`Config Exception: Cannot convert ${value} to array`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given value is a valid SERVICE_ENV variable.
|
||||
*
|
||||
* @param value - Value to validate.
|
||||
*/
|
||||
export function toServiceEnv(value: unknown): Env {
|
||||
assertServiceEnv(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Assertions
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function assertServiceEnv(value: unknown): asserts value is Env {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`Config Exception: Env ${value} is not a string`);
|
||||
}
|
||||
if ((SERVICE_ENV as unknown as string[]).includes(value) === false) {
|
||||
throw new Error(`Config Exception: Invalid env ${value} provided`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Parser = (value: unknown) => any;
|
||||
|
||||
export type Env = (typeof SERVICE_ENV)[number];
|
||||
3
api/libraries/config/mod.ts
Normal file
3
api/libraries/config/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./libraries/args.ts";
|
||||
export * from "./libraries/environment.ts";
|
||||
export * from "./libraries/parsers.ts";
|
||||
1
api/libraries/crypto/mod.ts
Normal file
1
api/libraries/crypto/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./password.ts";
|
||||
11
api/libraries/crypto/password.ts
Normal file
11
api/libraries/crypto/password.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as bcrypt from "@felix/bcrypt";
|
||||
|
||||
export const password = { hash, verify };
|
||||
|
||||
async function hash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password);
|
||||
}
|
||||
|
||||
async function verify(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.verify(password, hash);
|
||||
}
|
||||
48
api/libraries/database/accessor.ts
Normal file
48
api/libraries/database/accessor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Collection, type CollectionOptions, type Db, type Document, type MongoClient } from "mongodb";
|
||||
|
||||
import { container } from "./container.ts";
|
||||
|
||||
export function getDatabaseAccessor<TSchemas extends Record<string, Document>>(
|
||||
database: string,
|
||||
): DatabaseAccessor<TSchemas> {
|
||||
let instance: Db | undefined;
|
||||
return {
|
||||
get db(): Db {
|
||||
if (instance === undefined) {
|
||||
instance = this.client.db(database);
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
get client(): MongoClient {
|
||||
return container.get("client");
|
||||
},
|
||||
collection<TSchema extends keyof TSchemas>(
|
||||
name: TSchema,
|
||||
options?: CollectionOptions,
|
||||
): Collection<TSchemas[TSchema]> {
|
||||
return this.db.collection<TSchemas[TSchema]>(name.toString(), options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type DatabaseAccessor<TSchemas extends Record<string, Document>> = {
|
||||
/**
|
||||
* Database for given accessor.
|
||||
*/
|
||||
db: Db;
|
||||
|
||||
/**
|
||||
* Lazy loaded mongo client.
|
||||
*/
|
||||
client: MongoClient;
|
||||
|
||||
/**
|
||||
* Returns a reference to a MongoDB Collection. If it does not exist it will be created implicitly.
|
||||
*
|
||||
* Collection namespace validation is performed server-side.
|
||||
*
|
||||
* @param name - Collection name we wish to access.
|
||||
* @param options - Optional settings for the command.
|
||||
*/
|
||||
collection<TSchema extends keyof TSchemas>(name: TSchema, options?: CollectionOptions): Collection<TSchemas[TSchema]>;
|
||||
};
|
||||
10
api/libraries/database/config.ts
Normal file
10
api/libraries/database/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts";
|
||||
|
||||
export const config = {
|
||||
mongo: {
|
||||
host: getEnvironmentVariable("DB_MONGO_HOST", "localhost"),
|
||||
port: getEnvironmentVariable("DB_MONGO_PORT", toNumber, "27017"),
|
||||
user: getEnvironmentVariable("DB_MONGO_USER", "root"),
|
||||
pass: getEnvironmentVariable("DB_MONGO_PASSWORD", "password"),
|
||||
},
|
||||
};
|
||||
24
api/libraries/database/connection.ts
Normal file
24
api/libraries/database/connection.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
export function getMongoClient(config: MongoConnectionInfo) {
|
||||
return new MongoClient(getConnectionUrl(config));
|
||||
}
|
||||
|
||||
export function getConnectionUrl({ host, port, user, pass }: MongoConnectionInfo): MongoConnectionUrl {
|
||||
return `mongodb://${user}:${pass}@${host}:${port}`;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type MongoConnectionUrl = `mongodb://${string}:${string}@${string}:${number}`;
|
||||
|
||||
export type MongoConnectionInfo = {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
6
api/libraries/database/container.ts
Normal file
6
api/libraries/database/container.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Container } from "@valkyr/inverse";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
export const container = new Container<{
|
||||
client: MongoClient;
|
||||
}>("database");
|
||||
3
api/libraries/database/id.ts
Normal file
3
api/libraries/database/id.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { CreateIndexesOptions, IndexSpecification } from "mongodb";
|
||||
|
||||
export const idIndex: [IndexSpecification, CreateIndexesOptions] = [{ id: 1 }, { unique: true }];
|
||||
30
api/libraries/database/registrar.ts
Normal file
30
api/libraries/database/registrar.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { CreateIndexesOptions, Db, IndexSpecification } from "mongodb";
|
||||
|
||||
import { getCollectionsSet } from "./utilities.ts";
|
||||
|
||||
/**
|
||||
* Takes a mongo database and registers the event store collections and
|
||||
* indexes defined internally.
|
||||
*
|
||||
* @param db - Mongo database to register event store collections against.
|
||||
* @param registrars - List of registrars to register with the database.
|
||||
* @param logger - Logger method to print internal logs.
|
||||
*/
|
||||
export async function register(db: Db, registrars: Registrar[], logger?: (...args: any[]) => any) {
|
||||
const list = await getCollectionsSet(db);
|
||||
for (const { name, indexes } of registrars) {
|
||||
if (list.has(name) === false) {
|
||||
await db.createCollection(name);
|
||||
}
|
||||
for (const [indexSpec, options] of indexes) {
|
||||
await db.collection(name).createIndex(indexSpec, options);
|
||||
logger?.("Mongo Event Store > Collection '%s' is indexed [%O] with options %O", name, indexSpec, options ?? {});
|
||||
}
|
||||
logger?.("Mongo Event Store > Collection '%s' is registered", name);
|
||||
}
|
||||
}
|
||||
|
||||
export type Registrar = {
|
||||
name: string;
|
||||
indexes: [IndexSpecification, CreateIndexesOptions?][];
|
||||
};
|
||||
5
api/libraries/database/tasks/bootstrap.ts
Normal file
5
api/libraries/database/tasks/bootstrap.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { config } from "../config.ts";
|
||||
import { getMongoClient } from "../connection.ts";
|
||||
import { container } from "../container.ts";
|
||||
|
||||
container.set("client", getMongoClient(config.mongo));
|
||||
37
api/libraries/database/utilities.ts
Normal file
37
api/libraries/database/utilities.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Db } from "mongodb";
|
||||
import z, { ZodType } from "zod";
|
||||
|
||||
/**
|
||||
* Get a Set of collections that exists on a given mongo database instance.
|
||||
*
|
||||
* @param db - Mongo database to fetch collection list for.
|
||||
*/
|
||||
export async function getCollectionsSet(db: Db) {
|
||||
return db
|
||||
.listCollections()
|
||||
.toArray()
|
||||
.then((collections) => new Set(collections.map((c) => c.name)));
|
||||
}
|
||||
|
||||
export function toParsedDocuments<TSchema extends ZodType>(
|
||||
schema: TSchema,
|
||||
): (documents: unknown[]) => Promise<z.infer<TSchema>[]> {
|
||||
return async function (documents: unknown[]) {
|
||||
const parsed = [];
|
||||
for (const document of documents) {
|
||||
parsed.push(await schema.parseAsync(document));
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
}
|
||||
|
||||
export function toParsedDocument<TSchema extends ZodType>(
|
||||
schema: TSchema,
|
||||
): (document?: unknown) => Promise<z.infer<TSchema> | undefined> {
|
||||
return async function (document: unknown) {
|
||||
if (document === undefined || document === null) {
|
||||
return undefined;
|
||||
}
|
||||
return schema.parseAsync(document);
|
||||
};
|
||||
}
|
||||
268
api/libraries/event-store/aggregates/account.ts
Normal file
268
api/libraries/event-store/aggregates/account.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
import { Avatar, Contact, Email, Name, Phone, Strategy } from "relay/schemas";
|
||||
|
||||
import { db, toAccountDriver } from "~libraries/read-store/mod.ts";
|
||||
|
||||
import { eventStore } from "../event-store.ts";
|
||||
import { AccountCreatedData } from "../events/account.ts";
|
||||
import { Auditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Account extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "account";
|
||||
|
||||
id!: string;
|
||||
organizationId?: string;
|
||||
|
||||
type!: "admin" | "consultant" | "organization";
|
||||
|
||||
avatar?: Avatar;
|
||||
name?: Name;
|
||||
contact: Contact = {
|
||||
emails: [],
|
||||
phones: [],
|
||||
};
|
||||
strategies: Strategy[] = [];
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Account);
|
||||
|
||||
static create(data: AccountCreatedData, meta: Auditor): Account {
|
||||
return new Account().push({
|
||||
type: "account:created",
|
||||
data,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Account | undefined> {
|
||||
return this.$store.reduce({ name: "account", stream, reducer: this.#reducer });
|
||||
}
|
||||
|
||||
static async getByEmail(email: string): Promise<Account | undefined> {
|
||||
return this.$store.reduce({ name: "account", relation: Account.emailRelation(email), reducer: this.#reducer });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static emailRelation(email: string): `account:email:${string}` {
|
||||
return `account:email:${email}`;
|
||||
}
|
||||
|
||||
static passwordRelation(alias: string): `account:password:${string}` {
|
||||
return `account:password:${alias}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "account:created": {
|
||||
this.id = event.stream;
|
||||
this.organizationId = event.data.type === "organization" ? event.data.organizationId : undefined;
|
||||
this.type = event.data.type;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "account:avatar:added": {
|
||||
this.avatar = { url: event.data };
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "account:name:added": {
|
||||
this.name = event.data;
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "account:email:added": {
|
||||
this.contact.emails.push(event.data);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "account:phone:added": {
|
||||
this.contact.phones.push(event.data);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "strategy:email:added": {
|
||||
this.strategies.push({ type: "email", value: event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "strategy:password:added": {
|
||||
this.strategies.push({ type: "password", ...event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
addAvatar(url: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:avatar:added",
|
||||
data: url,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addName(name: Name, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:name:added",
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmail(email: Email, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addPhone(phone: Phone, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:phone:added",
|
||||
data: phone,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addRole(roleId: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:role:added",
|
||||
data: roleId,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmailStrategy(email: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "strategy:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addPasswordStrategy(alias: string, password: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "strategy:password:added",
|
||||
data: { alias, password },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
toSession(): Session {
|
||||
if (this.type === "organization") {
|
||||
if (this.organizationId === undefined) {
|
||||
throw new Error("Account .toSession failed, no organization id present");
|
||||
}
|
||||
return {
|
||||
type: this.type,
|
||||
accountId: this.id,
|
||||
organizationId: this.organizationId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: this.type,
|
||||
accountId: this.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Session =
|
||||
| {
|
||||
type: "organization";
|
||||
accountId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
| {
|
||||
type: "admin" | "consultant";
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("account:created", async ({ stream, data }) => {
|
||||
const schema: any = {
|
||||
id: stream,
|
||||
type: data.type,
|
||||
contact: {
|
||||
emails: [],
|
||||
phones: [],
|
||||
},
|
||||
strategies: [],
|
||||
roles: [],
|
||||
};
|
||||
if (data.type === "organization") {
|
||||
schema.organizationId = data.organizationId;
|
||||
}
|
||||
await db.collection("accounts").insertOne(toAccountDriver(schema));
|
||||
});
|
||||
|
||||
projector.on("account:avatar:added", async ({ stream: id, data: url }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } });
|
||||
});
|
||||
|
||||
projector.on("account:name:added", async ({ stream: id, data: name }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $set: { name } });
|
||||
});
|
||||
|
||||
projector.on("account:email:added", async ({ stream: id, data: email }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
|
||||
});
|
||||
|
||||
projector.on("account:phone:added", async ({ stream: id, data: phone }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { "contact.phones": phone } });
|
||||
});
|
||||
|
||||
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
|
||||
});
|
||||
|
||||
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
|
||||
await eventStore.relations.insert(Account.emailRelation(email), id);
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
|
||||
});
|
||||
|
||||
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
|
||||
await eventStore.relations.insert(Account.passwordRelation(strategy.alias), id);
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
|
||||
});
|
||||
78
api/libraries/event-store/aggregates/code.ts
Normal file
78
api/libraries/event-store/aggregates/code.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
|
||||
import { CodeIdentity } from "../events/code.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
|
||||
export class Code extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "code";
|
||||
|
||||
id!: string;
|
||||
|
||||
identity!: CodeIdentity;
|
||||
value!: string;
|
||||
|
||||
createdAt!: Date;
|
||||
claimedAt?: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Code);
|
||||
|
||||
static create(identity: CodeIdentity): Code {
|
||||
return new Code().push({
|
||||
type: "code:created",
|
||||
data: {
|
||||
identity,
|
||||
value: crypto
|
||||
.getRandomValues(new Uint8Array(5))
|
||||
.map((v) => v % 10)
|
||||
.join(""),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Code | undefined> {
|
||||
return this.$store.reduce({
|
||||
name: "code",
|
||||
stream,
|
||||
reducer: this.#reducer,
|
||||
});
|
||||
}
|
||||
|
||||
get isClaimed(): boolean {
|
||||
return this.claimedAt !== undefined;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Folder
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "code:created": {
|
||||
this.id = event.stream;
|
||||
this.value = event.data.value;
|
||||
this.identity = event.data.identity;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "code:claimed": {
|
||||
this.claimedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
claim(): this {
|
||||
return this.push({
|
||||
type: "code:claimed",
|
||||
stream: this.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
api/libraries/event-store/aggregates/mod.ts
Normal file
8
api/libraries/event-store/aggregates/mod.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { AggregateFactory } from "@valkyr/event-store";
|
||||
|
||||
import { Account } from "./account.ts";
|
||||
import { Code } from "./code.ts";
|
||||
import { Organization } from "./organization.ts";
|
||||
import { Role } from "./role.ts";
|
||||
|
||||
export const aggregates = new AggregateFactory([Account, Code, Organization, Role]);
|
||||
65
api/libraries/event-store/aggregates/organization.ts
Normal file
65
api/libraries/event-store/aggregates/organization.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "~libraries/read-store/mod.ts";
|
||||
|
||||
import { Auditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Organization extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "organization";
|
||||
|
||||
id!: string;
|
||||
|
||||
name!: string;
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Organization);
|
||||
|
||||
static create(name: string, meta: Auditor): Organization {
|
||||
return new Organization().push({
|
||||
type: "organization:created",
|
||||
data: { name },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Organization | undefined> {
|
||||
return this.$store.reduce({ name: "organization", stream, reducer: this.#reducer });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "organization:created": {
|
||||
this.id = event.stream;
|
||||
this.name = event.data.name;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("organization:created", async ({ stream: id, data: { name }, created }) => {
|
||||
await db.collection("organizations").insertOne({
|
||||
id,
|
||||
name,
|
||||
createdAt: getDate(created),
|
||||
});
|
||||
});
|
||||
118
api/libraries/event-store/aggregates/role.ts
Normal file
118
api/libraries/event-store/aggregates/role.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "~libraries/read-store/database.ts";
|
||||
|
||||
import type { Auditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Role extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "role";
|
||||
|
||||
id!: string;
|
||||
|
||||
name!: string;
|
||||
permissions: { [resource: string]: Set<string> } = {};
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Role);
|
||||
|
||||
static create(data: RoleCreatedData, meta: Auditor): Role {
|
||||
return new Role().push({
|
||||
type: "role:created",
|
||||
data,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Role | undefined> {
|
||||
return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "role:created": {
|
||||
this.id = event.stream;
|
||||
this.createdAt = getDate(event.created);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "role:name-set": {
|
||||
this.name = event.data;
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "role:permissions-set": {
|
||||
for (const operation of event.data) {
|
||||
if (operation.type === "grant") {
|
||||
if (this.permissions[operation.resource] === undefined) {
|
||||
this.permissions[operation.resource] = new Set();
|
||||
}
|
||||
this.permissions[operation.resource].add(operation.action);
|
||||
}
|
||||
if (operation.type === "deny") {
|
||||
if (operation.action === undefined) {
|
||||
delete this.permissions[operation.resource];
|
||||
} else {
|
||||
this.permissions[operation.resource]?.delete(operation.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setName(name: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
type: "role:name-set",
|
||||
stream: this.id,
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
|
||||
return this.push({
|
||||
type: "role:permissions-set",
|
||||
stream: this.id,
|
||||
data: operations,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
|
||||
await db.collection("roles").insertOne({
|
||||
id: stream,
|
||||
name,
|
||||
permissions: permissions.reduce(
|
||||
(map, permission) => {
|
||||
map[permission.resource] = permission.actions;
|
||||
return map;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
),
|
||||
});
|
||||
});
|
||||
25
api/libraries/event-store/event-store.ts
Normal file
25
api/libraries/event-store/event-store.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { EventStore } from "@valkyr/event-store";
|
||||
import { MongoAdapter } from "@valkyr/event-store/mongo";
|
||||
|
||||
import { container } from "~libraries/database/container.ts";
|
||||
|
||||
import { aggregates } from "./aggregates/mod.ts";
|
||||
import { events } from "./events/mod.ts";
|
||||
import { projector } from "./projector.ts";
|
||||
|
||||
export const eventStore = new EventStore({
|
||||
adapter: new MongoAdapter(() => container.get("client"), "balto:event-store"),
|
||||
events,
|
||||
aggregates,
|
||||
snapshot: "auto",
|
||||
});
|
||||
|
||||
eventStore.onEventsInserted(async (records, { batch }) => {
|
||||
if (batch !== undefined) {
|
||||
await projector.pushMany(batch, records);
|
||||
} else {
|
||||
for (const record of records) {
|
||||
await projector.push(record, { hydrated: false, outdated: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
29
api/libraries/event-store/events/account.ts
Normal file
29
api/libraries/event-store/events/account.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import { email, name, phone } from "relay/schemas";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
const created = z.discriminatedUnion([
|
||||
z.object({
|
||||
type: z.literal("admin"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("consultant"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("organization"),
|
||||
organizationId: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export default [
|
||||
event.type("account:created").data(created).meta(auditor),
|
||||
event.type("account:avatar:added").data(z.string()).meta(auditor),
|
||||
event.type("account:name:added").data(name).meta(auditor),
|
||||
event.type("account:email:added").data(email).meta(auditor),
|
||||
event.type("account:phone:added").data(phone).meta(auditor),
|
||||
event.type("account:role:added").data(z.string()).meta(auditor),
|
||||
];
|
||||
|
||||
export type AccountCreatedData = z.infer<typeof created>;
|
||||
7
api/libraries/event-store/events/auditor.ts
Normal file
7
api/libraries/event-store/events/auditor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const auditor = z.object({
|
||||
accountId: z.string(),
|
||||
});
|
||||
|
||||
export type Auditor = z.infer<typeof auditor>;
|
||||
30
api/libraries/event-store/events/code.ts
Normal file
30
api/libraries/event-store/events/code.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
const identity = z.discriminatedUnion([
|
||||
z.object({
|
||||
type: z.literal("admin"),
|
||||
accountId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("consultant"),
|
||||
accountId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("organization"),
|
||||
organizationId: z.string(),
|
||||
accountId: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export default [
|
||||
event.type("code:created").data(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
identity,
|
||||
}),
|
||||
),
|
||||
event.type("code:claimed"),
|
||||
];
|
||||
|
||||
export type CodeIdentity = z.infer<typeof identity>;
|
||||
11
api/libraries/event-store/events/mod.ts
Normal file
11
api/libraries/event-store/events/mod.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { EventFactory } from "@valkyr/event-store";
|
||||
|
||||
import account from "./account.ts";
|
||||
import code from "./code.ts";
|
||||
import organization from "./organization.ts";
|
||||
import role from "./role.ts";
|
||||
import strategy from "./strategy.ts";
|
||||
|
||||
export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]);
|
||||
|
||||
export type EventStoreFactory = typeof events;
|
||||
11
api/libraries/event-store/events/organization.ts
Normal file
11
api/libraries/event-store/events/organization.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
export default [
|
||||
event
|
||||
.type("organization:created")
|
||||
.data(z.object({ name: z.string() }))
|
||||
.meta(auditor),
|
||||
];
|
||||
37
api/libraries/event-store/events/role.ts
Normal file
37
api/libraries/event-store/events/role.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
const created = z.object({
|
||||
name: z.string(),
|
||||
permissions: z.array(
|
||||
z.object({
|
||||
resource: z.string(),
|
||||
actions: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const operation = z.discriminatedUnion([
|
||||
z.object({
|
||||
type: z.literal("grant"),
|
||||
resource: z.string(),
|
||||
action: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("deny"),
|
||||
resource: z.string(),
|
||||
action: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export default [
|
||||
event.type("role:created").data(created).meta(auditor),
|
||||
event.type("role:name-set").data(z.string()).meta(auditor),
|
||||
event.type("role:permissions-set").data(z.array(operation)).meta(auditor),
|
||||
];
|
||||
|
||||
export type RoleCreatedData = z.infer<typeof created>;
|
||||
|
||||
export type RolePermissionOperation = z.infer<typeof operation>;
|
||||
13
api/libraries/event-store/events/strategy.ts
Normal file
13
api/libraries/event-store/events/strategy.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
export default [
|
||||
event.type("strategy:email:added").data(z.string()).meta(auditor),
|
||||
event.type("strategy:passkey:added").meta(auditor),
|
||||
event
|
||||
.type("strategy:password:added")
|
||||
.data(z.object({ alias: z.string(), password: z.string() }))
|
||||
.meta(auditor),
|
||||
];
|
||||
2
api/libraries/event-store/mod.ts
Normal file
2
api/libraries/event-store/mod.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./event-store.ts";
|
||||
export * from "./projector.ts";
|
||||
5
api/libraries/event-store/projector.ts
Normal file
5
api/libraries/event-store/projector.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Projector } from "@valkyr/event-store";
|
||||
|
||||
import { EventStoreFactory } from "./events/mod.ts";
|
||||
|
||||
export const projector = new Projector<EventStoreFactory>();
|
||||
48
api/libraries/logger/chalk.ts
Normal file
48
api/libraries/logger/chalk.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { HexValue } from "./color/hex.ts";
|
||||
import { type BGColor, type Color, hexToBgColor, hexToColor, type Modifier, styles } from "./color/styles.ts";
|
||||
|
||||
export const chalk = {
|
||||
color(hex: HexValue): (value: string) => string {
|
||||
const color = hexToColor(hex);
|
||||
return (value: string) => `${color}${value}${styles.modifier.reset}`;
|
||||
},
|
||||
bgColor(hex: HexValue): (value: string) => string {
|
||||
const color = hexToBgColor(hex);
|
||||
return (value: string) => `${color}${value}${styles.modifier.reset}`;
|
||||
},
|
||||
} as Chalk;
|
||||
|
||||
for (const key in styles.modifier) {
|
||||
chalk[key as Modifier] = function (value: string) {
|
||||
return toModifiedValue(key as Modifier, value);
|
||||
};
|
||||
}
|
||||
|
||||
for (const key in styles.color) {
|
||||
chalk[key as Color] = function (value: string) {
|
||||
return toColorValue(key as Color, value);
|
||||
};
|
||||
}
|
||||
|
||||
for (const key in styles.bgColor) {
|
||||
chalk[key as BGColor] = function (value: string) {
|
||||
return toBGColorValue(key as BGColor, value);
|
||||
};
|
||||
}
|
||||
|
||||
function toModifiedValue(key: Modifier, value: string): string {
|
||||
return `${styles.modifier[key]}${value}${styles.modifier.reset}`;
|
||||
}
|
||||
|
||||
function toColorValue(key: Color, value: string): string {
|
||||
return `${styles.color[key]}${value}${styles.modifier.reset}`;
|
||||
}
|
||||
|
||||
function toBGColorValue(key: BGColor, value: string): string {
|
||||
return `${styles.bgColor[key]}${value}${styles.modifier.reset}`;
|
||||
}
|
||||
|
||||
type Chalk = Record<Modifier | Color | BGColor, (value: string) => string> & {
|
||||
color(hex: HexValue): (value: string) => string;
|
||||
bgColor(hex: HexValue): (value: string) => string;
|
||||
};
|
||||
28
api/libraries/logger/color/hex.ts
Normal file
28
api/libraries/logger/color/hex.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { rgbToAnsi256 } from "./rgb.ts";
|
||||
|
||||
/**
|
||||
* Convert provided hex value to closest 256-Color value.
|
||||
*
|
||||
* @param hex - Hex to convert.
|
||||
*/
|
||||
export function hexToAnsi256(hex: HexValue) {
|
||||
const { r, g, b } = hexToRGB(hex);
|
||||
return rgbToAnsi256(r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a hex value and return its RGB values.
|
||||
*
|
||||
* @param hex - Hex to convert to RGB
|
||||
* @returns
|
||||
*/
|
||||
export function hexToRGB(hex: HexValue): { r: number; g: number; b: number } {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export type HexValue =
|
||||
`#${string | number}${string | number}${string | number}${string | number}${string | number}${string | number}`;
|
||||
24
api/libraries/logger/color/rgb.ts
Normal file
24
api/libraries/logger/color/rgb.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Convert RGB to the nearest 256-color ANSI value
|
||||
*
|
||||
* @param r - Red value.
|
||||
* @param g - Green value.
|
||||
* @param b - Blue value.
|
||||
*/
|
||||
export function rgbToAnsi256(r: number, g: number, b: number): number {
|
||||
if (r === g && g === b) {
|
||||
if (r < 8) return 16;
|
||||
if (r > 248) return 231;
|
||||
return Math.round(((r - 8) / 247) * 24) + 232;
|
||||
}
|
||||
|
||||
// Map RGB to 6×6×6 color cube (16–231)
|
||||
const conv = (val: number) => Math.round(val / 51);
|
||||
const ri = conv(r);
|
||||
const gi = conv(g);
|
||||
const bi = conv(b);
|
||||
|
||||
return 16 + 36 * ri + 6 * gi + bi;
|
||||
}
|
||||
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
76
api/libraries/logger/color/styles.ts
Normal file
76
api/libraries/logger/color/styles.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { hexToAnsi256, HexValue } from "./hex.ts";
|
||||
import { toEscapeSequence } from "./utilities.ts";
|
||||
|
||||
export const styles = {
|
||||
modifier: {
|
||||
reset: toEscapeSequence(0), // Reset to normal
|
||||
bold: toEscapeSequence(1), // Bold text
|
||||
dim: toEscapeSequence(2), // Dim text
|
||||
italic: toEscapeSequence(3), // Italic text
|
||||
underline: toEscapeSequence(4), // Underlined text
|
||||
overline: toEscapeSequence(53), // Overline text
|
||||
inverse: toEscapeSequence(7), // Inverse
|
||||
hidden: toEscapeSequence(8), // Hidden text
|
||||
strikethrough: toEscapeSequence(9), // Strikethrough
|
||||
},
|
||||
|
||||
color: {
|
||||
black: toEscapeSequence(30), // Black color
|
||||
red: toEscapeSequence(31), // Red color
|
||||
green: toEscapeSequence(32), // Green color
|
||||
yellow: toEscapeSequence(33), // Yellow color
|
||||
blue: toEscapeSequence(34), // Blue color
|
||||
magenta: toEscapeSequence(35), // Magenta color
|
||||
cyan: toEscapeSequence(36), // Cyan color
|
||||
white: toEscapeSequence(37), // White color
|
||||
orange: hexToColor("#FFA500"),
|
||||
|
||||
// Bright colors
|
||||
blackBright: toEscapeSequence(90),
|
||||
gray: toEscapeSequence(90), // Alias for blackBright
|
||||
grey: toEscapeSequence(90), // Alias for blackBright
|
||||
redBright: toEscapeSequence(91),
|
||||
greenBright: toEscapeSequence(92),
|
||||
yellowBright: toEscapeSequence(93),
|
||||
blueBright: toEscapeSequence(94),
|
||||
magentaBright: toEscapeSequence(95),
|
||||
cyanBright: toEscapeSequence(96),
|
||||
whiteBright: toEscapeSequence(97),
|
||||
},
|
||||
|
||||
bgColor: {
|
||||
bgBlack: toEscapeSequence(40),
|
||||
bgRed: toEscapeSequence(41),
|
||||
bgGreen: toEscapeSequence(42),
|
||||
bgYellow: toEscapeSequence(43),
|
||||
bgBlue: toEscapeSequence(44),
|
||||
bgMagenta: toEscapeSequence(45),
|
||||
bgCyan: toEscapeSequence(46),
|
||||
bgWhite: toEscapeSequence(47),
|
||||
bgOrange: hexToBgColor("#FFA500"),
|
||||
|
||||
// Bright background colors
|
||||
bgBlackBright: toEscapeSequence(100),
|
||||
bgGray: toEscapeSequence(100), // Alias for bgBlackBright
|
||||
bgGrey: toEscapeSequence(100), // Alias for bgBlackBright
|
||||
bgRedBright: toEscapeSequence(101),
|
||||
bgGreenBright: toEscapeSequence(102),
|
||||
bgYellowBright: toEscapeSequence(103),
|
||||
bgBlueBright: toEscapeSequence(104),
|
||||
bgMagentaBright: toEscapeSequence(105),
|
||||
bgCyanBright: toEscapeSequence(106),
|
||||
bgWhiteBright: toEscapeSequence(107),
|
||||
},
|
||||
};
|
||||
|
||||
export function hexToColor(hex: HexValue): string {
|
||||
return toEscapeSequence(`38;5;${hexToAnsi256(hex)}`); // Foreground color
|
||||
}
|
||||
|
||||
export function hexToBgColor(hex: HexValue): string {
|
||||
return toEscapeSequence(`48;5;${hexToAnsi256(hex)}`); // Background color
|
||||
}
|
||||
|
||||
export type Modifier = keyof typeof styles.modifier;
|
||||
export type Color = keyof typeof styles.color;
|
||||
export type BGColor = keyof typeof styles.bgColor;
|
||||
3
api/libraries/logger/color/utilities.ts
Normal file
3
api/libraries/logger/color/utilities.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function toEscapeSequence(value: string | number): `\x1b[${string}m` {
|
||||
return `\x1b[${value}m`;
|
||||
}
|
||||
5
api/libraries/logger/config.ts
Normal file
5
api/libraries/logger/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { getArgsVariable } from "~libraries/config/mod.ts";
|
||||
|
||||
export const config = {
|
||||
level: getArgsVariable("LOG_LEVEL", "info"),
|
||||
};
|
||||
19
api/libraries/logger/format/event-store.ts
Normal file
19
api/libraries/logger/format/event-store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { EventValidationError } from "@valkyr/event-store";
|
||||
|
||||
import type { Level } from "../level.ts";
|
||||
import { getTracedAt } from "../stack.ts";
|
||||
|
||||
export function toEventStoreLog(arg: any, level: Level): any {
|
||||
if (arg instanceof EventValidationError) {
|
||||
const obj: any = {
|
||||
origin: "EventStore",
|
||||
message: arg.message,
|
||||
at: getTracedAt(arg.stack, "/api/domains"),
|
||||
data: arg.errors,
|
||||
};
|
||||
if (level === "debug") {
|
||||
obj.stack = arg.stack;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
18
api/libraries/logger/format/server.ts
Normal file
18
api/libraries/logger/format/server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ServerError } from "@spec/relay";
|
||||
|
||||
import type { Level } from "../level.ts";
|
||||
import { getTracedAt } from "../stack.ts";
|
||||
|
||||
export function toServerLog(arg: any, level: Level): any {
|
||||
if (arg instanceof ServerError) {
|
||||
const obj: any = {
|
||||
message: arg.message,
|
||||
data: arg.data,
|
||||
at: getTracedAt(arg.stack, "/api/domains"),
|
||||
};
|
||||
if (level === "debug") {
|
||||
obj.stack = arg.stack;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
8
api/libraries/logger/level.ts
Normal file
8
api/libraries/logger/level.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const logLevel = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warning: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
export type Level = "debug" | "error" | "warning" | "info";
|
||||
95
api/libraries/logger/logger.ts
Normal file
95
api/libraries/logger/logger.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { chalk } from "./chalk.ts";
|
||||
import { type Level, logLevel } from "./level.ts";
|
||||
|
||||
export class Logger {
|
||||
#level: Level = "info";
|
||||
#config: Config;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
get #prefix(): [string?] {
|
||||
if (this.#config.prefix !== undefined) {
|
||||
return [chalk.bold(chalk.green(this.#config.prefix))];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the highest logging level in the order of debug, info, warn, error.
|
||||
*
|
||||
* When value is 'info', info, warn and error will be logged and debug
|
||||
* will be ignored.
|
||||
*
|
||||
* @param value Highest log level.
|
||||
*/
|
||||
level(value: Level): this {
|
||||
this.#level = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new logger instance with the given name as prefix.
|
||||
*
|
||||
* @param name - Prefix name.
|
||||
*/
|
||||
prefix(name: string): Logger {
|
||||
return new Logger({ prefix: name, loggers: this.#config.loggers }).level(this.#level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a debug message to terminal.
|
||||
*/
|
||||
debug(...args: any[]) {
|
||||
if (this.#isLevelEnabled(0)) {
|
||||
console.log(new Date(), chalk.bold("Debug"), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a info message to terminal.
|
||||
*/
|
||||
info(...args: any[]) {
|
||||
if (this.#isLevelEnabled(1)) {
|
||||
console.log(new Date(), chalk.bold(chalk.blue("Info")), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a warning message to terminal.
|
||||
*/
|
||||
warn(...args: any[]) {
|
||||
if (this.#isLevelEnabled(2)) {
|
||||
console.log(new Date(), chalk.bold(chalk.orange("Warning")), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a errpr message to terminal.
|
||||
*/
|
||||
error(...args: any[]) {
|
||||
if (this.#isLevelEnabled(3)) {
|
||||
console.log(new Date(), chalk.bold(chalk.red("Error")), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
#isLevelEnabled(level: 0 | 1 | 2 | 3): boolean {
|
||||
return level >= logLevel[this.#level];
|
||||
}
|
||||
|
||||
#toFormattedArg = (arg: any): string => {
|
||||
for (const logger of this.#config.loggers) {
|
||||
const res = logger(arg, this.#level);
|
||||
if (res !== undefined) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
};
|
||||
}
|
||||
|
||||
type Config = {
|
||||
prefix?: string;
|
||||
loggers: ((arg: any, level: Level) => any)[];
|
||||
};
|
||||
7
api/libraries/logger/mod.ts
Normal file
7
api/libraries/logger/mod.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { toEventStoreLog } from "./format/event-store.ts";
|
||||
import { toServerLog } from "./format/server.ts";
|
||||
import { Logger } from "./logger.ts";
|
||||
|
||||
export const logger = new Logger({
|
||||
loggers: [toServerLog, toEventStoreLog],
|
||||
});
|
||||
20
api/libraries/logger/stack.ts
Normal file
20
api/libraries/logger/stack.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Fetch the most closest relevant error from the local code base so it can
|
||||
* be more easily traced to its source.
|
||||
*
|
||||
* @param stack - Error stack.
|
||||
* @param search - Relevant stack line search value.
|
||||
*/
|
||||
export function getTracedAt(stack: string | undefined, search: string): string | undefined {
|
||||
if (stack === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const firstMatch = stack.split("\n").find((line) => line.includes(search));
|
||||
if (firstMatch === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return firstMatch
|
||||
.replace(/^.*?(file:\/\/\/)/, "$1")
|
||||
.replace(/\)$/, "")
|
||||
.trim();
|
||||
}
|
||||
11
api/libraries/read-store/.tasks/bootstrap.ts
Normal file
11
api/libraries/read-store/.tasks/bootstrap.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { idIndex } from "~libraries/database/id.ts";
|
||||
import { register } from "~libraries/database/registrar.ts";
|
||||
|
||||
import { db } from "../database.ts";
|
||||
|
||||
await register(db.db, [
|
||||
{
|
||||
name: "accounts",
|
||||
indexes: [idIndex],
|
||||
},
|
||||
]);
|
||||
6
api/libraries/read-store/account/methods.ts
Normal file
6
api/libraries/read-store/account/methods.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { db, takeOne } from "../database.ts";
|
||||
import { type AccountSchema, fromAccountDriver } from "./schema.ts";
|
||||
|
||||
export async function getAccountById(id: string): Promise<AccountSchema | undefined> {
|
||||
return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne);
|
||||
}
|
||||
36
api/libraries/read-store/account/schema.ts
Normal file
36
api/libraries/read-store/account/schema.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const account = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.object({
|
||||
given: z.string(),
|
||||
family: z.string(),
|
||||
}),
|
||||
email: z.email(),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Parsers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const select = account;
|
||||
const insert = account;
|
||||
|
||||
export function toAccountDriver(documents: unknown): AccountInsert {
|
||||
return insert.parse(documents);
|
||||
}
|
||||
|
||||
export function fromAccountDriver(documents: unknown[]): AccountSchema[] {
|
||||
return documents.map((document) => select.parse(document));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type AccountSchema = z.infer<typeof select>;
|
||||
export type AccountInsert = z.infer<typeof insert>;
|
||||
12
api/libraries/read-store/database.ts
Normal file
12
api/libraries/read-store/database.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { config } from "~config";
|
||||
import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
|
||||
|
||||
import { AccountInsert } from "./account/schema.ts";
|
||||
|
||||
export const db = getDatabaseAccessor<{
|
||||
accounts: AccountInsert;
|
||||
}>(`${config.name}:read-store`);
|
||||
|
||||
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {
|
||||
return documents[0];
|
||||
}
|
||||
3
api/libraries/read-store/mod.ts
Normal file
3
api/libraries/read-store/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./account/methods.ts";
|
||||
export * from "./account/schema.ts";
|
||||
export * from "./database.ts";
|
||||
415
api/libraries/server/api.ts
Normal file
415
api/libraries/server/api.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
BadRequestError,
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
Route,
|
||||
RouteMethod,
|
||||
ServerError,
|
||||
type ServerErrorResponse,
|
||||
UnauthorizedError,
|
||||
ZodValidationError,
|
||||
} from "@spec/relay";
|
||||
import { treeifyError } from "zod";
|
||||
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
|
||||
import { getRequestContext } from "./context.ts";
|
||||
import { req } from "./request.ts";
|
||||
|
||||
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
export class Api {
|
||||
readonly #index = new Map<string, Route>();
|
||||
|
||||
/**
|
||||
* Route maps funneling registered routes to the specific methods supported by
|
||||
* the relay instance.
|
||||
*/
|
||||
readonly routes: Routes = {
|
||||
POST: [],
|
||||
GET: [],
|
||||
PUT: [],
|
||||
PATCH: [],
|
||||
DELETE: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* List of paths in the '${method} ${path}' format allowing us to quickly throw
|
||||
* errors if a duplicate route path is being added.
|
||||
*/
|
||||
readonly #paths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Instantiate a new Api instance.
|
||||
*
|
||||
* @param routes - Initial list of routes to register with the api.
|
||||
*/
|
||||
constructor(routes: Route[] = []) {
|
||||
this.register(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register relays with the API instance allowing for decoupled registration
|
||||
* of server side handling of relay contracts.
|
||||
*
|
||||
* @param routes - Relays to register with the instance.
|
||||
*/
|
||||
register(routes: Route[]): this {
|
||||
const methods: (keyof typeof this.routes)[] = [];
|
||||
for (const route of routes) {
|
||||
const path = `${route.method} ${route.path}`;
|
||||
if (this.#paths.has(path)) {
|
||||
throw new Error(`Router > Path ${path} already exists`);
|
||||
}
|
||||
this.#paths.add(path);
|
||||
this.routes[route.method].push(route);
|
||||
methods.push(route.method);
|
||||
this.#index.set(`${route.method} ${route.path}`, route);
|
||||
}
|
||||
for (const method of methods) {
|
||||
this.routes[method].sort(byStaticPriority);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes request and returns a `Response` instance.
|
||||
*
|
||||
* @param request - REST request to pass to a route handler.
|
||||
*/
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// ### Route
|
||||
// Locate a route matching the incoming request method and path.
|
||||
|
||||
const resolved = this.#getResolvedRoute(request.method, url.pathname);
|
||||
if (resolved === undefined) {
|
||||
return toResponse(
|
||||
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
}),
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// ### Handle
|
||||
// Execute request and return a response.
|
||||
|
||||
const response = await this.#getRouteResponse(resolved, request).catch((error) =>
|
||||
this.#getErrorResponse(error, resolved.route, request),
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve a route based on the given method and pathname.
|
||||
*
|
||||
* @param method - HTTP method.
|
||||
* @param url - HTTP request url.
|
||||
*/
|
||||
#getResolvedRoute(method: string, url: string): ResolvedRoute | undefined {
|
||||
assertMethod(method);
|
||||
for (const route of this.routes[method]) {
|
||||
if (route.match(url) === true) {
|
||||
return { route, params: route.getParsedParams(url) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the request on the given route and return a `Response` instance.
|
||||
*
|
||||
* @param resolved - Route and paramter details resolved for the request.
|
||||
* @param request - Request instance to resolve.
|
||||
*/
|
||||
async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// ### Args
|
||||
// Arguments is passed to every route handler and provides a suite of functionality
|
||||
// and request data.
|
||||
|
||||
const args: any[] = [];
|
||||
|
||||
// ### Input
|
||||
// Generate route input which contains a map fo params, query, and/or body. If
|
||||
// none of these are present then the input is not added to the final argument
|
||||
// context of the handler.
|
||||
|
||||
const input: {
|
||||
params?: object;
|
||||
query?: object;
|
||||
body?: unknown;
|
||||
} = {
|
||||
params: undefined,
|
||||
query: undefined,
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
// ### Access
|
||||
// Check the access requirements of the route and run any additional checks
|
||||
// if nessesary before proceeding with further request handling.
|
||||
// 1. All routes needs access assignment, else we consider it an internal error.
|
||||
// 2. If access requires a session we throw Unauthorized if the request is not authenticated.
|
||||
// 3. If access is an array of access resources, we check that each resources can be
|
||||
// accessed by the request.
|
||||
|
||||
if (route.state.access === undefined) {
|
||||
return toResponse(
|
||||
new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`),
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
if (route.state.access === "session" && req.isAuthenticated === false) {
|
||||
return toResponse(new UnauthorizedError(), request);
|
||||
}
|
||||
|
||||
if (Array.isArray(route.state.access)) {
|
||||
for (const hasAccess of route.state.access) {
|
||||
if (hasAccess() === false) {
|
||||
return toResponse(new ForbiddenError(), request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ### Params
|
||||
// If the route has params we want to coerce the values to the expected types.
|
||||
|
||||
if (route.state.params !== undefined) {
|
||||
const result = await route.state.params.safeParseAsync(params);
|
||||
if (result.success === false) {
|
||||
return toResponse(new ZodValidationError("Invalid request params", treeifyError(result.error)), request);
|
||||
}
|
||||
input.params = result.data;
|
||||
}
|
||||
|
||||
// ### Query
|
||||
// If the route has a query schema we need to validate and parse the query.
|
||||
|
||||
if (route.state.query !== undefined) {
|
||||
const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {});
|
||||
if (result.success === false) {
|
||||
return toResponse(new ZodValidationError("Invalid request query", treeifyError(result.error)), request);
|
||||
}
|
||||
input.query = result.data;
|
||||
}
|
||||
|
||||
// ### Body
|
||||
// If the route has a body schema we need to validate and parse the body.
|
||||
|
||||
if (route.state.body !== undefined) {
|
||||
const body = await this.#getRequestBody(request);
|
||||
const result = await route.state.body.safeParseAsync(body);
|
||||
if (result.success === false) {
|
||||
return toResponse(new ZodValidationError("Invalid request body", treeifyError(result.error)), request);
|
||||
}
|
||||
input.body = result.data;
|
||||
}
|
||||
|
||||
if (input.params !== undefined || input.query !== undefined || input.body !== undefined) {
|
||||
args.push(input);
|
||||
}
|
||||
|
||||
// ### Context
|
||||
// Request context pass to every route as the last argument.
|
||||
|
||||
args.push(getRequestContext(request));
|
||||
|
||||
// ### Handler
|
||||
// Execute the route handler and apply the result.
|
||||
|
||||
if (route.state.handle === undefined) {
|
||||
return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request);
|
||||
}
|
||||
|
||||
return toResponse(await route.state.handle(...args), request);
|
||||
}
|
||||
|
||||
#getErrorResponse(error: unknown, route: Route, request: Request): Response {
|
||||
if (route?.state.hooks?.onError !== undefined) {
|
||||
return route.state.hooks.onError(error);
|
||||
}
|
||||
if (error instanceof ServerError) {
|
||||
return toResponse(error, request);
|
||||
}
|
||||
logger.error(error);
|
||||
if (error instanceof Error) {
|
||||
return toResponse(new InternalServerError(error.message), request);
|
||||
}
|
||||
return toResponse(new InternalServerError(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves request body and returns it.
|
||||
*
|
||||
* @param request - Request to resolve body from.
|
||||
* @param files - Files to populate if present.
|
||||
*/
|
||||
async #getRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||
let body: Record<string, unknown> = {};
|
||||
|
||||
const type = request.headers.get("content-type");
|
||||
if (!type || request.method === "GET") {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (type.includes("json")) {
|
||||
body = await request.json();
|
||||
}
|
||||
|
||||
if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
for (const [name, value] of Array.from(formData.entries())) {
|
||||
body[name] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw new BadRequestError(`Malformed FormData`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Assert that the given method string is a valid routing method.
|
||||
*
|
||||
* @param candidate - Method candidate.
|
||||
*/
|
||||
function assertMethod(candidate: string): asserts candidate is RouteMethod {
|
||||
if (!SUPPORTED_MEHODS.includes(candidate)) {
|
||||
throw new Error(`Router > Unsupported method '${candidate}'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorting method for routes to ensure that static properties takes precedence
|
||||
* for when a route is matched against incoming requests.
|
||||
*
|
||||
* @param a - Route A
|
||||
* @param b - Route B
|
||||
*/
|
||||
function byStaticPriority(a: Route, b: Route) {
|
||||
const aSegments = a.path.split("/");
|
||||
const bSegments = b.path.split("/");
|
||||
|
||||
const maxLength = Math.max(aSegments.length, bSegments.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const aSegment = aSegments[i] || "";
|
||||
const bSegment = bSegments[i] || "";
|
||||
|
||||
const isADynamic = aSegment.startsWith(":");
|
||||
const isBDynamic = bSegment.startsWith(":");
|
||||
|
||||
if (isADynamic !== isBDynamic) {
|
||||
return isADynamic ? 1 : -1;
|
||||
}
|
||||
|
||||
if (isADynamic === false && aSegment !== bSegment) {
|
||||
return aSegment.localeCompare(bSegment);
|
||||
}
|
||||
}
|
||||
|
||||
return a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and return query object from the provided search parameters, or undefined
|
||||
* if the search parameters does not have any entries.
|
||||
*
|
||||
* @param searchParams - Search params to create a query object from.
|
||||
*/
|
||||
function toQuery(searchParams: URLSearchParams): object | undefined {
|
||||
if (searchParams.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a server side request result and returns a fetch Response.
|
||||
*
|
||||
* @param result - Result to send back as a Response.
|
||||
* @param request - Request instance.
|
||||
*/
|
||||
export function toResponse(result: unknown, request: Request): Response {
|
||||
const method = request.method;
|
||||
|
||||
if (result instanceof Response) {
|
||||
if (method === "HEAD") {
|
||||
return new Response(null, {
|
||||
status: result.status,
|
||||
statusText: result.statusText,
|
||||
headers: new Headers(result.headers),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result instanceof ServerError) {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
data: result.data,
|
||||
},
|
||||
} satisfies ServerErrorResponse);
|
||||
|
||||
return new Response(method === "HEAD" ? null : body, {
|
||||
statusText: result.message || "Internal Server Error",
|
||||
status: result.status || 500,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
data: result ?? null,
|
||||
});
|
||||
|
||||
return new Response(method === "HEAD" ? null : body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Routes = {
|
||||
POST: Route[];
|
||||
GET: Route[];
|
||||
PUT: Route[];
|
||||
PATCH: Route[];
|
||||
DELETE: Route[];
|
||||
};
|
||||
|
||||
type ResolvedRoute = {
|
||||
route: Route;
|
||||
params: any;
|
||||
};
|
||||
16
api/libraries/server/context.ts
Normal file
16
api/libraries/server/context.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { RouteContext } from "@spec/relay";
|
||||
|
||||
export function getRequestContext(request: Request): RouteContext {
|
||||
return {
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@spec/relay" {
|
||||
interface RouteContext {
|
||||
/**
|
||||
* Current request instance being handled.
|
||||
*/
|
||||
request: Request;
|
||||
}
|
||||
}
|
||||
5
api/libraries/server/mod.ts
Normal file
5
api/libraries/server/mod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./api.ts";
|
||||
export * from "./context.ts";
|
||||
export * from "./modules.ts";
|
||||
export * from "./request.ts";
|
||||
export * from "./storage.ts";
|
||||
40
api/libraries/server/modules.ts
Normal file
40
api/libraries/server/modules.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Route } from "@spec/relay";
|
||||
|
||||
/**
|
||||
* Resolve and return all routes that has been created under any 'routes'
|
||||
* folders that can be found under the given path.
|
||||
*
|
||||
* If the filter is empty, all paths are resolved, otherwise only paths
|
||||
* declared in the array is resolved.
|
||||
*
|
||||
* @param path - Path to resolve routes from.
|
||||
* @param filter - List of modules to include.
|
||||
* @param routes - List of routes that has been resolved.
|
||||
*/
|
||||
export async function resolveRoutes(path: string, routes: Route[] = []): Promise<Route[]> {
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
await loadRoutes(`${path}/${entry.name}/routes`, routes, [name]);
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
async function loadRoutes(path: string, routes: Route[], modules: string[]): Promise<void> {
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
await loadRoutes(`${path}/${entry.name}`, routes, [...modules, entry.name]);
|
||||
} else {
|
||||
if (!entry.name.endsWith(".ts") || entry.name.endsWith("i9n.ts")) {
|
||||
continue;
|
||||
}
|
||||
const { default: route } = (await import(`${path}/${entry.name}`)) as { default: Route };
|
||||
if (route instanceof Route === false) {
|
||||
throw new Error(
|
||||
`Router Violation: Could not load '${path}/${entry.name}' as it does not export a default Route instance.`,
|
||||
);
|
||||
}
|
||||
routes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
api/libraries/server/request.ts
Normal file
57
api/libraries/server/request.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { asyncLocalStorage } from "./storage.ts";
|
||||
|
||||
export const req = {
|
||||
get store() {
|
||||
const store = asyncLocalStorage.getStore();
|
||||
if (store === undefined) {
|
||||
throw new Error("Request > AsyncLocalStorage not defined.");
|
||||
}
|
||||
return store;
|
||||
},
|
||||
|
||||
get socket() {
|
||||
return this.store.socket;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get store that is potentially undefined.
|
||||
* Typically used when utility functions might run in and out of request scope.
|
||||
*/
|
||||
get unsafeStore() {
|
||||
return asyncLocalStorage.getStore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the request is authenticated.
|
||||
*/
|
||||
get isAuthenticated() {
|
||||
return this.session !== undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current session.
|
||||
*/
|
||||
get session() {
|
||||
return this.store.session;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the meta information stored in the request.
|
||||
*/
|
||||
get info() {
|
||||
return this.store.info;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC 2.0 notification to the request if sent through a
|
||||
* WebSocket connection.
|
||||
*
|
||||
* @param method - Method to send notification to.
|
||||
* @param params - Params to pass to the method.
|
||||
*/
|
||||
notify(method: string, params: any): void {
|
||||
this.socket?.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ReqContext = typeof req;
|
||||
16
api/libraries/server/storage.ts
Normal file
16
api/libraries/server/storage.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import type { Session } from "~libraries/auth/mod.ts";
|
||||
|
||||
export const asyncLocalStorage = new AsyncLocalStorage<{
|
||||
session?: Session;
|
||||
info: {
|
||||
method: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
};
|
||||
socket?: WebSocket;
|
||||
response: {
|
||||
headers: Headers;
|
||||
};
|
||||
}>();
|
||||
81
api/libraries/socket/channels.ts
Normal file
81
api/libraries/socket/channels.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Params } from "@valkyr/json-rpc";
|
||||
|
||||
import { Sockets } from "./sockets.ts";
|
||||
|
||||
export class Channels {
|
||||
readonly #channels = new Map<string, Sockets>();
|
||||
|
||||
/**
|
||||
* Add a new channel.
|
||||
*
|
||||
* @param channel
|
||||
*/
|
||||
add(channel: string): this {
|
||||
this.#channels.set(channel, new Sockets());
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a channel.
|
||||
*
|
||||
* @param channel
|
||||
*/
|
||||
del(channel: string): this {
|
||||
this.#channels.delete(channel);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add socket to the given channel. If the channel does not exist it is
|
||||
* automatically created.
|
||||
*
|
||||
* @param channel - Channel to add socket to.
|
||||
* @param socket - Socket to add to the channel.
|
||||
*/
|
||||
join(channel: string, socket: WebSocket): this {
|
||||
const sockets = this.#channels.get(channel);
|
||||
if (sockets === undefined) {
|
||||
this.#channels.set(channel, new Sockets().add(socket));
|
||||
} else {
|
||||
sockets.add(socket);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a socket from the given channel.
|
||||
*
|
||||
* @param channel - Channel to leave.
|
||||
* @param socket - Socket to remove from the channel.
|
||||
*/
|
||||
leave(channel: string, socket: WebSocket): this {
|
||||
this.#channels.get(channel)?.del(socket);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC notification to all sockets in given channel.
|
||||
*
|
||||
* @param channel - Channel to emit method to.
|
||||
* @param method - Method to send the notification to.
|
||||
* @param params - Message data to send to the clients.
|
||||
*/
|
||||
notify(channel: string, method: string, params: Params): this {
|
||||
this.#channels.get(channel)?.notify(method, params);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits data to all registered WebSocket connections in the given channel.
|
||||
* Data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView.
|
||||
*
|
||||
* @param channel - Channel to emit message to.
|
||||
* @param data - Data to send to each connected socket in the channel.
|
||||
*/
|
||||
send(channel: string, data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
|
||||
this.#channels.get(channel)?.send(data);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export const channels = new Channels();
|
||||
1
api/libraries/socket/mod.ts
Normal file
1
api/libraries/socket/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sockets } from "./sockets.ts";
|
||||
49
api/libraries/socket/sockets.ts
Normal file
49
api/libraries/socket/sockets.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Params } from "@valkyr/json-rpc";
|
||||
|
||||
export class Sockets {
|
||||
readonly #sockets = new Set<WebSocket>();
|
||||
|
||||
/**
|
||||
* Add a socket to the pool.
|
||||
*
|
||||
* @param socket - WebSocket to add.
|
||||
*/
|
||||
add(socket: WebSocket): this {
|
||||
this.#sockets.add(socket);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a socket from the pool.
|
||||
*
|
||||
* @param socket - WebSocket to remove.
|
||||
*/
|
||||
del(socket: WebSocket): this {
|
||||
this.#sockets.delete(socket);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC notification to all connected sockets.
|
||||
*
|
||||
* @param method - Method to send the notification to.
|
||||
* @param params - Message data to send to the clients.
|
||||
*/
|
||||
notify(method: string, params: Params): this {
|
||||
this.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits data to all registered WebSocket connections. Data can be a string,
|
||||
* a Blob, an ArrayBuffer, or an ArrayBufferView.
|
||||
*
|
||||
* @param data - Data to send to each connected socket.
|
||||
*/
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
|
||||
this.#sockets.forEach((socket) => socket.send(data));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export const sockets = new Sockets();
|
||||
60
api/libraries/socket/upgrade.ts
Normal file
60
api/libraries/socket/upgrade.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { toJsonRpc } from "@valkyr/json-rpc";
|
||||
|
||||
import { Session } from "~libraries/auth/mod.ts";
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
|
||||
import { sockets } from "./sockets.ts";
|
||||
|
||||
export function upgrade(request: Request, session?: Session) {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
logger.prefix("Socket").info("socket connected", { session });
|
||||
sockets.add(socket);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
logger.prefix("Socket").info("socket disconnected", { session });
|
||||
sockets.del(socket);
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data === "ping") {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = toJsonRpc(event.data);
|
||||
|
||||
logger.prefix("Socket").info(body);
|
||||
|
||||
asyncLocalStorage.run(
|
||||
{
|
||||
session,
|
||||
info: {
|
||||
method: body.method!,
|
||||
start: Date.now(),
|
||||
},
|
||||
socket,
|
||||
response: {
|
||||
headers: new Headers(),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
api
|
||||
.handleCommand(body)
|
||||
.then((response) => {
|
||||
if (response !== undefined) {
|
||||
logger.info({ response });
|
||||
socket.send(JSON.stringify(response));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.info({ error });
|
||||
socket.send(JSON.stringify(error));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
4
api/libraries/testing/config.ts
Normal file
4
api/libraries/testing/config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const config = {
|
||||
mongodb: "mongo:8.0.3",
|
||||
postgres: "postgres:17",
|
||||
};
|
||||
154
api/libraries/testing/containers/api-container.ts
Normal file
154
api/libraries/testing/containers/api-container.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { getAvailablePort } from "@std/net";
|
||||
import cookie from "cookie";
|
||||
|
||||
import { auth, Session } from "~libraries/auth/mod.ts";
|
||||
import { Code } from "~libraries/code/aggregates/code.ts";
|
||||
import { handler } from "~libraries/server/handler.ts";
|
||||
|
||||
import { Api, QueryMethod } from "../.generated/api.ts";
|
||||
|
||||
export class ApiTestContainer {
|
||||
#server?: Deno.HttpServer;
|
||||
#client?: Api;
|
||||
#cookie?: string;
|
||||
#session?: Session;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
get accountId(): string | undefined {
|
||||
if (this.#session?.valid === true) {
|
||||
return this.#session.accountId;
|
||||
}
|
||||
}
|
||||
|
||||
get client() {
|
||||
if (this.#client === undefined) {
|
||||
throw new Error("ApiContainer > .start() has not been executed.");
|
||||
}
|
||||
return this.#client;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async start(): Promise<this> {
|
||||
const port = await getAvailablePort();
|
||||
this.#server = await Deno.serve({ port, hostname: "127.0.0.1" }, handler);
|
||||
this.#client = makeApiClient(port, {
|
||||
onBeforeRequest: (headers: Headers) => {
|
||||
if (this.#cookie !== undefined) {
|
||||
headers.set("cookie", this.#cookie);
|
||||
}
|
||||
},
|
||||
onAfterResponse: (response) => {
|
||||
const cookie = response.headers.get("set-cookie");
|
||||
if (cookie !== null) {
|
||||
this.#cookie = cookie;
|
||||
}
|
||||
},
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.#server?.shutdown();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async authorize(accountId: string): Promise<void> {
|
||||
const code = await Code.create({ identity: { type: "admin", accountId } }).save();
|
||||
await this.client.auth.code(accountId, code.id, code.value, {});
|
||||
this.#session = await this.getSession();
|
||||
}
|
||||
|
||||
async getSession(): Promise<Session | undefined> {
|
||||
const token = cookie.parse(this.#cookie ?? "").token;
|
||||
if (token !== undefined) {
|
||||
const session = await auth.resolve(token);
|
||||
if (session.valid === true) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unauthorize(): void {
|
||||
this.#cookie = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function makeApiClient(
|
||||
port: number,
|
||||
{
|
||||
onBeforeRequest,
|
||||
onAfterResponse,
|
||||
}: {
|
||||
onBeforeRequest: (headers: Headers) => void;
|
||||
onAfterResponse: (response: Response) => void;
|
||||
},
|
||||
): Api {
|
||||
return new Api({
|
||||
async command(payload) {
|
||||
const headers = new Headers();
|
||||
onBeforeRequest(headers);
|
||||
headers.set("content-type", "application/json");
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/v1/command`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (response.status >= 300) {
|
||||
console.error(
|
||||
`Command '${payload.method}' responded with error status '${response.status} ${response.statusText}'.`,
|
||||
);
|
||||
}
|
||||
if (response.headers.get("content-type")?.includes("json") === true) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
},
|
||||
async query(method: QueryMethod, path: string, query: Record<string, unknown>, body: any = {}) {
|
||||
const headers = new Headers();
|
||||
onBeforeRequest(headers);
|
||||
if (method !== "GET") {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}${getSearchQuery(query)}`, {
|
||||
method,
|
||||
headers,
|
||||
body: method === "GET" ? undefined : JSON.stringify(body),
|
||||
});
|
||||
onAfterResponse(response);
|
||||
const text = await response.text();
|
||||
if (response.status >= 300) {
|
||||
console.error(`Query '${path}' responded with error status '${response.status} ${response.statusText}'.`);
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
if (response.headers.get("content-type")?.includes("json") === true) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(query: Record<string, unknown>): string {
|
||||
const search: string[] = [];
|
||||
for (const key in query) {
|
||||
search.push(`${key}=${query[key]}`);
|
||||
}
|
||||
if (search.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `?${search.join("&")}`;
|
||||
}
|
||||
41
api/libraries/testing/containers/database-container.ts
Normal file
41
api/libraries/testing/containers/database-container.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
|
||||
|
||||
import { container } from "~database/container.ts";
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
import { bootstrap } from "~libraries/utilities/bootstrap.ts";
|
||||
import { API_DOMAINS_DIR, API_PACKAGES_DIR } from "~paths";
|
||||
|
||||
export class DatabaseTestContainer {
|
||||
constructor(readonly mongo: MongoTestContainer) {
|
||||
container.set("client", mongo.client);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async start(): Promise<this> {
|
||||
logger.prefix("Database").info("DatabaseTestContainer Started");
|
||||
|
||||
await bootstrap(API_DOMAINS_DIR);
|
||||
await bootstrap(API_PACKAGES_DIR);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async truncate() {
|
||||
const promises: Promise<any>[] = [];
|
||||
for (const dbName of ["balto:auth", "balto:code", "balto:consultant", "balto:task"]) {
|
||||
const db = this.mongo.client.db(dbName);
|
||||
const collections = await db.listCollections().toArray();
|
||||
promises.push(...collections.map(({ name }) => db.collection(name).deleteMany({})));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
logger.prefix("Database").info("DatabaseTestContainer stopped");
|
||||
}
|
||||
}
|
||||
178
api/libraries/testing/containers/test-container.ts
Normal file
178
api/libraries/testing/containers/test-container.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
|
||||
|
||||
import { config } from "../config.ts";
|
||||
import { ApiTestContainer } from "./api-container.ts";
|
||||
import { DatabaseTestContainer } from "./database-container.ts";
|
||||
|
||||
export class TestContainer {
|
||||
readonly id = crypto.randomUUID();
|
||||
|
||||
// ### Enablers
|
||||
// A map of services to enable when the TestContainer is started. These toggles
|
||||
// must be toggled before the container is started.
|
||||
|
||||
#with: With = {
|
||||
mongodb: false,
|
||||
database: false,
|
||||
api: false,
|
||||
};
|
||||
|
||||
// ### Needs
|
||||
|
||||
#needs: Needs = {
|
||||
mongodb: [],
|
||||
database: ["mongodb"],
|
||||
api: ["mongodb", "database"],
|
||||
};
|
||||
|
||||
// ### Services
|
||||
// Any services that has been enabled will be running under the following
|
||||
// assignments. Make sure to .stop any running services to avoid shutdown
|
||||
// leaks.
|
||||
|
||||
#mongodb?: MongoTestContainer;
|
||||
#database?: DatabaseTestContainer;
|
||||
#api?: ApiTestContainer;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
get accountId() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.accountId;
|
||||
}
|
||||
|
||||
get mongodb(): MongoTestContainer {
|
||||
if (this.#mongodb === undefined) {
|
||||
throw new Error("TestContainer > .withMongo() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#mongodb;
|
||||
}
|
||||
|
||||
get database(): DatabaseTestContainer {
|
||||
if (this.#database === undefined) {
|
||||
throw new Error("TestContainer > .withDatabase() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#database;
|
||||
}
|
||||
|
||||
get api() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.client;
|
||||
}
|
||||
|
||||
get authorize() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.authorize.bind(this.#api);
|
||||
}
|
||||
|
||||
get unauthorize() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.unauthorize.bind(this.#api);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Builder
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
withMongo(): this {
|
||||
this.#with.mongodb = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
withDatabase(): this {
|
||||
this.#with.database = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
withApi(): this {
|
||||
this.#with.api = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async start(): Promise<this> {
|
||||
const promises: Promise<void>[] = [];
|
||||
if (this.#isNeeded("mongodb") === true) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
this.#mongodb = await MongoTestContainer.start(config.mongodb);
|
||||
if (this.#isNeeded("database") === true) {
|
||||
this.#database = await new DatabaseTestContainer(this.mongodb).start();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
if (this.#isNeeded("api") === true) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
this.#api = await new ApiTestContainer().start();
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop(): Promise<this> {
|
||||
await this.#api?.stop();
|
||||
await this.#database?.stop();
|
||||
await this.#mongodb?.stop();
|
||||
|
||||
this.#api = undefined;
|
||||
this.#database = undefined;
|
||||
this.#mongodb = undefined;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
#isNeeded(target: keyof With): boolean {
|
||||
if (this.#with[target] !== false) {
|
||||
return true;
|
||||
}
|
||||
for (const key in this.#needs) {
|
||||
if (this.#with[key as keyof With] !== false && this.#needs[key as keyof With].includes(target) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Needs = Record<keyof With, (keyof With)[]>;
|
||||
|
||||
type With = {
|
||||
mongodb: boolean;
|
||||
database: boolean;
|
||||
api: boolean;
|
||||
};
|
||||
24
api/libraries/testing/describe.ts
Normal file
24
api/libraries/testing/describe.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as assertSuite from "@std/assert";
|
||||
import * as bddSuite from "@std/testing/bdd";
|
||||
|
||||
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
|
||||
|
||||
import { authorize } from "./utilities/account.ts";
|
||||
|
||||
export function describe(name: string, runner: TestRunner): (container: TestContainer) => void {
|
||||
return (container: TestContainer) =>
|
||||
bddSuite.describe(name, () => runner(container, bddSuite, assertSuite, { authorize: authorize(container) }));
|
||||
}
|
||||
|
||||
export type TestRunner = (
|
||||
container: TestContainer,
|
||||
bdd: {
|
||||
[key in keyof typeof bddSuite]: (typeof bddSuite)[key];
|
||||
},
|
||||
assert: {
|
||||
[key in keyof typeof assertSuite]: (typeof assertSuite)[key];
|
||||
},
|
||||
utils: {
|
||||
authorize: ReturnType<typeof authorize>;
|
||||
},
|
||||
) => void;
|
||||
68
api/libraries/testing/utilities/account.ts
Normal file
68
api/libraries/testing/utilities/account.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { EventData } from "@valkyr/event-store";
|
||||
|
||||
import { AccountCreated, AccountEmailAdded } from "~libraries/auth/.generated/events.ts";
|
||||
import { Account } from "~libraries/auth/aggregates/account.ts";
|
||||
import { Role } from "~libraries/auth/aggregates/role.ts";
|
||||
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
|
||||
|
||||
type AuthorizationOptions = {
|
||||
name?: { family?: string; given?: string };
|
||||
email?: Partial<EventData<AccountEmailAdded>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a function which provides the ability to create a new account which
|
||||
* is authorized and ready to use for testing authorized requests.
|
||||
*
|
||||
* @param container - Container to authorize against.
|
||||
*/
|
||||
export function authorize(container: TestContainer): AuthorizeFn {
|
||||
return async (data: EventData<AccountCreated>, { name = {}, email = {} }: AuthorizationOptions = {}) => {
|
||||
const role = await makeRole(data.type).save();
|
||||
const account = await Account.create(data, "test")
|
||||
.addName(name?.family ?? "Doe", name?.given ?? "John", "test")
|
||||
.addEmail({ value: "john.doe@fixture.none", type: "work", primary: true, verified: true, ...email }, "test")
|
||||
.addRole(role.id, "test")
|
||||
.save();
|
||||
await container.authorize(account.id);
|
||||
return account;
|
||||
};
|
||||
}
|
||||
|
||||
function makeRole(type: "admin" | "consultant" | "organization"): Role {
|
||||
switch (type) {
|
||||
case "admin": {
|
||||
return Role.create(
|
||||
{
|
||||
name: "Admin",
|
||||
permissions: [
|
||||
{ resource: "admin", actions: ["create", "update", "delete"] },
|
||||
{ resource: "consultant", actions: ["create", "update", "delete"] },
|
||||
{ resource: "organization", actions: ["create", "update", "delete"] },
|
||||
],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
}
|
||||
case "consultant": {
|
||||
return Role.create(
|
||||
{
|
||||
name: "Consultant",
|
||||
permissions: [{ resource: "consultant", actions: ["create", "update", "delete"] }],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
}
|
||||
case "organization": {
|
||||
return Role.create(
|
||||
{
|
||||
name: "Organization",
|
||||
permissions: [{ resource: "organization", actions: ["create", "update", "delete"] }],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AuthorizeFn = (data: EventData<AccountCreated>, optional?: AuthorizationOptions) => Promise<Account>;
|
||||
62
api/libraries/utilities/dedent.ts
Normal file
62
api/libraries/utilities/dedent.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Removes excess indentation caused by using multiline template strings.
|
||||
*
|
||||
* Ported from `dedent-js` solution.
|
||||
*
|
||||
* @see https://github.com/MartinKolarik/dedent-js
|
||||
*
|
||||
* @param templateStrings - Template strings to dedent.
|
||||
*
|
||||
* @example
|
||||
* {
|
||||
* nested: {
|
||||
* examples: [
|
||||
* dedent(`
|
||||
* I am 8 spaces off from the beginning of this file.
|
||||
* But I will be 2 spaces based on the trimmed distance
|
||||
* of the first line.
|
||||
* `),
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export function dedent(templateStrings: TemplateStringsArray | string, ...values: any[]) {
|
||||
const matches = [];
|
||||
const strings = typeof templateStrings === "string" ? [templateStrings] : templateStrings.slice();
|
||||
|
||||
// Remove trailing whitespace.
|
||||
|
||||
strings[strings.length - 1] = strings[strings.length - 1].replace(/\r?\n([\t ]*)$/, "");
|
||||
|
||||
// Find all line breaks to determine the highest common indentation level.
|
||||
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
const match = strings[i].match(/\n[\t ]+/g);
|
||||
if (match) {
|
||||
matches.push(...match);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the common indentation from all strings.
|
||||
|
||||
if (matches.length) {
|
||||
const size = Math.min(...matches.map((value) => value.length - 1));
|
||||
const pattern = new RegExp(`\n[\t ]{${size}}`, "g");
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
strings[i] = strings[i].replace(pattern, "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading whitespace.
|
||||
|
||||
strings[0] = strings[0].replace(/^\r?\n/, "");
|
||||
|
||||
// Perform interpolation.
|
||||
|
||||
let string = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
string += values[i] + strings[i + 1];
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
41
api/libraries/utilities/generate.ts
Normal file
41
api/libraries/utilities/generate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Traverse path and look for a `generate.ts` file in each folder found under
|
||||
* the given path. If a `generate.ts` file is found it is imported so its content
|
||||
* is executed.
|
||||
*
|
||||
* @param path - Path to resolve `generate.ts` files.
|
||||
* @param filter - Which folders found under the given path to ignore.
|
||||
*/
|
||||
export async function generate(path: string, filter: string[] = []): Promise<void> {
|
||||
const generate: string[] = [];
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
const moduleName = path.split("/").pop();
|
||||
if (moduleName === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (filter.length > 0 && filter.includes(moduleName) === false) {
|
||||
continue;
|
||||
}
|
||||
const filePath = `${path}/${entry.name}/.tasks/generate.ts`;
|
||||
if (await hasFile(filePath)) {
|
||||
generate.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const filePath of generate) {
|
||||
await import(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasFile(filePath: string) {
|
||||
try {
|
||||
await Deno.lstat(filePath);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) {
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
5
api/modules/auth/routes/authenticate.ts
Normal file
5
api/modules/auth/routes/authenticate.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { authenticate } from "@spec/modules/auth/routes/authenticate.ts";
|
||||
|
||||
export default authenticate.access("public").handle(async ({ body }) => {
|
||||
console.log({ body });
|
||||
});
|
||||
23
api/package.json
Normal file
23
api/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "deno --allow-all --watch-hmr=routes/ server.ts",
|
||||
"migrate": "deno run --allow-all .tasks/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1",
|
||||
"@spec/modules": "workspace:*",
|
||||
"@spec/relay": "workspace:*",
|
||||
"@spec/shared": "workspace:*",
|
||||
"@std/cli": "npm:@jsr/std__cli@1",
|
||||
"@std/dotenv": "npm:@jsr/std__dotenv@0.225",
|
||||
"@std/fs": "npm:@jsr/std__fs@1",
|
||||
"@std/path": "npm:@jsr/std__path@1",
|
||||
"@valkyr/auth": "npm:@jsr/valkyr__auth@2",
|
||||
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.5",
|
||||
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
|
||||
"cookie": "1",
|
||||
"mongodb": "6",
|
||||
"zod": "4"
|
||||
}
|
||||
}
|
||||
93
api/server.ts
Normal file
93
api/server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { resolve } from "@std/path";
|
||||
import cookie from "cookie";
|
||||
|
||||
import { auth, type Session } from "~libraries/auth/mod.ts";
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
import { asyncLocalStorage } from "~libraries/server/mod.ts";
|
||||
import { Api, resolveRoutes } from "~libraries/server/mod.ts";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
|
||||
const MODULES_DIR = resolve(import.meta.dirname!, "modules");
|
||||
|
||||
const log = logger.prefix("Server");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Bootstrap
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await import("./tasks/bootstrap.ts");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Service
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const api = new Api(await resolveRoutes(MODULES_DIR));
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Server
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
Deno.serve(
|
||||
{
|
||||
port: config.port,
|
||||
hostname: config.host,
|
||||
onListen({ port, hostname }) {
|
||||
logger.prefix("Server").info(`Listening at http://${hostname}:${port}`);
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// ### Session
|
||||
|
||||
let session: Session | undefined;
|
||||
|
||||
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
|
||||
if (token !== undefined) {
|
||||
const resolved = await auth.resolve(token);
|
||||
if (resolved.valid === false) {
|
||||
return new Response(resolved.message, {
|
||||
status: 401,
|
||||
headers: {
|
||||
"set-cookie": cookie.serialize("token", "", config.cookie(0)),
|
||||
},
|
||||
});
|
||||
}
|
||||
session = resolved;
|
||||
}
|
||||
|
||||
// ### Headers
|
||||
// Set the default headers.
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
// ### Handle
|
||||
|
||||
const ts = performance.now();
|
||||
|
||||
return asyncLocalStorage.run(
|
||||
{
|
||||
session,
|
||||
info: {
|
||||
method: request.url,
|
||||
start: Date.now(),
|
||||
},
|
||||
response: {
|
||||
headers,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return api.fetch(request).finally(() => {
|
||||
log.info(`${request.method} ${url.pathname} [${((performance.now() - ts) / 1000).toLocaleString()} seconds]`);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
68
api/tasks/bootstrap.ts
Normal file
68
api/tasks/bootstrap.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
|
||||
const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries");
|
||||
|
||||
const log = logger.prefix("Bootstrap");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Database
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await import("~libraries/database/tasks/bootstrap.ts");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Packages
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await bootstrap(LIBRARIES_DIR);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Traverse path and look for a `bootstrap.ts` file in each folder found under
|
||||
* the given path. If a `boostrap.ts` file is found it is imported so its content
|
||||
* is executed.
|
||||
*
|
||||
* @param path - Path to resolve `bootstrap.ts` files.
|
||||
*/
|
||||
export async function bootstrap(path: string): Promise<void> {
|
||||
const bootstrap: { name: string; path: string }[] = [];
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
const moduleName = path.split("/").pop();
|
||||
if (moduleName === undefined) {
|
||||
continue;
|
||||
}
|
||||
const filePath = `${path}/${entry.name}/.tasks/bootstrap.ts`;
|
||||
if (await hasFile(filePath)) {
|
||||
bootstrap.push({ name: entry.name, path: filePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of bootstrap) {
|
||||
log.info(entry.name);
|
||||
await import(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasFile(filePath: string) {
|
||||
try {
|
||||
await Deno.lstat(filePath);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) {
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
66
api/tasks/migrate.ts
Normal file
66
api/tasks/migrate.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import { exists } from "@std/fs";
|
||||
|
||||
import { config } from "~libraries/database/config.ts";
|
||||
import { getMongoClient } from "~libraries/database/connection.ts";
|
||||
import { container } from "~libraries/database/container.ts";
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Dependencies
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const client = getMongoClient(config.mongo);
|
||||
|
||||
container.set("client", client);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Migrate
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const db = client.db("api:migrations");
|
||||
const collection = db.collection<MigrationDocument>("migrations");
|
||||
|
||||
const { default: journal } = await import(resolve(import.meta.dirname!, "migrations", "meta", "_journal.json"), {
|
||||
with: { type: "json" },
|
||||
});
|
||||
|
||||
const migrations =
|
||||
(await collection.findOne({ name: journal.name })) ?? ({ name: journal.name, entries: [] } as MigrationDocument);
|
||||
|
||||
for (const entry of journal.entries) {
|
||||
const migrationFileName = `${String(entry.idx).padStart(4, "0")}_${entry.name}.ts`;
|
||||
if (migrations.entries.includes(migrationFileName)) {
|
||||
continue;
|
||||
}
|
||||
const migrationPath = resolve(import.meta.dirname!, "migrations", migrationFileName);
|
||||
if (await exists(migrationPath)) {
|
||||
await import(migrationPath);
|
||||
await collection.updateOne(
|
||||
{
|
||||
name: journal.name,
|
||||
},
|
||||
{
|
||||
$set: { name: journal.name },
|
||||
$push: { entries: migrationFileName }, // Assuming 'entries' is an array
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
},
|
||||
);
|
||||
logger.info(`Migrated ${migrationPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
type MigrationDocument = {
|
||||
name: string;
|
||||
entries: string[];
|
||||
};
|
||||
|
||||
process.exit(0);
|
||||
4
api/tasks/migrations/meta/_journal.json
Normal file
4
api/tasks/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "api",
|
||||
"entries": []
|
||||
}
|
||||
1
apps/README.md
Normal file
1
apps/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Apps
|
||||
24
apps/react/.gitignore
vendored
Normal file
24
apps/react/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
1
apps/react/.npmrc
Normal file
1
apps/react/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@jsr:registry=https://npm.jsr.io
|
||||
69
apps/react/README.md
Normal file
69
apps/react/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
13
apps/react/index.html
Normal file
13
apps/react/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
apps/react/package.json
Normal file
34
apps/react/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5",
|
||||
"@tanstack/react-router": "1",
|
||||
"@valkyr/db": "1",
|
||||
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1",
|
||||
"fast-equals": "5",
|
||||
"react": "19",
|
||||
"react-dom": "19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9",
|
||||
"@types/react": "19",
|
||||
"@types/react-dom": "19",
|
||||
"@vitejs/plugin-react": "4",
|
||||
"eslint": "9",
|
||||
"eslint-plugin-react-hooks": "5",
|
||||
"eslint-plugin-react-refresh": "0.4",
|
||||
"globals": "16",
|
||||
"typescript": "5",
|
||||
"typescript-eslint": "8",
|
||||
"vite": "7"
|
||||
}
|
||||
}
|
||||
1
apps/react/public/vite.svg
Normal file
1
apps/react/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
apps/react/src/App.css
Normal file
42
apps/react/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
36
apps/react/src/App.tsx
Normal file
36
apps/react/src/App.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import "./App.css";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import viteLogo from "/vite.svg";
|
||||
|
||||
import reactLogo from "./assets/react.svg";
|
||||
import { Session } from "./components/Session.tsx";
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">Click on the Vite and React logos to learn more</p>
|
||||
<Session />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
267
apps/react/src/adapters/http.ts
Normal file
267
apps/react/src/adapters/http.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
assertServerErrorResponse,
|
||||
type RelayAdapter,
|
||||
type RelayInput,
|
||||
type RelayResponse,
|
||||
ServerError,
|
||||
type ServerErrorResponse,
|
||||
type ServerErrorType,
|
||||
} from "@spec/relay";
|
||||
|
||||
export class HttpAdapter implements RelayAdapter {
|
||||
/**
|
||||
* Instantiate a new HttpAdapter instance.
|
||||
*
|
||||
* @param options - Adapter options.
|
||||
*/
|
||||
constructor(readonly options: HttpAdapterOptions) {}
|
||||
|
||||
/**
|
||||
* Override the initial url value set by instantiator.
|
||||
*/
|
||||
set url(value: string) {
|
||||
this.options.url = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the URL value from options object.
|
||||
*/
|
||||
get url() {
|
||||
return this.options.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full URL from given endpoint.
|
||||
*
|
||||
* @param endpoint - Endpoint to get url for.
|
||||
*/
|
||||
getUrl(endpoint: string): string {
|
||||
return `${this.url}${endpoint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send fetch request to the configured endpoint.
|
||||
*
|
||||
* @param input - Relay input parameters to use for the request.
|
||||
*/
|
||||
async json({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
|
||||
const init: RequestInit = { method, headers };
|
||||
|
||||
// ### Before Request
|
||||
// If any before request hooks has been defined, we run them here passing in the
|
||||
// request headers for further modification.
|
||||
|
||||
await this.#beforeRequest(headers);
|
||||
|
||||
// ### Content Type
|
||||
// JSON requests are always of the type 'application/json' and this ensures that
|
||||
// we override any custom pre-hook values for 'content-type' when executing the
|
||||
// request via the 'json' method.
|
||||
|
||||
headers.set("content-type", "application/json");
|
||||
|
||||
// ### Body
|
||||
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
// ### Response
|
||||
|
||||
return this.request(`${endpoint}${query}`, init);
|
||||
}
|
||||
|
||||
async data({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
|
||||
const init: RequestInit = { method, headers };
|
||||
|
||||
// ### Before Request
|
||||
// If any before request hooks has been defined, we run them here passing in the
|
||||
// request headers for further modification.
|
||||
|
||||
await this.#beforeRequest(headers);
|
||||
|
||||
// ### Content Type
|
||||
// For multipart uploads we let the browser set the correct boundaries.
|
||||
|
||||
headers.delete("content-type");
|
||||
|
||||
// ### Body
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (body !== undefined) {
|
||||
for (const key in body) {
|
||||
const entity = body[key];
|
||||
if (entity === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(entity)) {
|
||||
const isFileArray = entity.length > 0 && entity.every((candidate) => candidate instanceof File);
|
||||
if (isFileArray) {
|
||||
for (const file of entity) {
|
||||
formData.append(key, file, file.name);
|
||||
}
|
||||
} else {
|
||||
formData.append(key, JSON.stringify(entity));
|
||||
}
|
||||
} else {
|
||||
if (entity instanceof File) {
|
||||
formData.append(key, entity, entity.name);
|
||||
} else {
|
||||
formData.append(key, typeof entity === "string" ? entity : JSON.stringify(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
init.body = formData;
|
||||
}
|
||||
|
||||
// ### Response
|
||||
|
||||
return this.request(`${endpoint}${query}`, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a fetch request using the given fetch options and returns
|
||||
* a relay formatted response.
|
||||
*
|
||||
* @param endpoint - Which endpoint to submit request to.
|
||||
* @param init - Request init details to submit with the request.
|
||||
*/
|
||||
async request(endpoint: string, init?: RequestInit): Promise<RelayResponse> {
|
||||
return this.#toResponse(await fetch(this.getUrl(endpoint), init));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run before request operations.
|
||||
*
|
||||
* @param headers - Headers to pass to hooks.
|
||||
*/
|
||||
async #beforeRequest(headers: Headers) {
|
||||
if (this.options.hooks?.beforeRequest !== undefined) {
|
||||
for (const hook of this.options.hooks.beforeRequest) {
|
||||
await hook(headers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fetch response to a compliant relay response.
|
||||
*
|
||||
* @param response - Fetch response to convert.
|
||||
*/
|
||||
async #toResponse(response: Response): Promise<RelayResponse> {
|
||||
const type = response.headers.get("content-type");
|
||||
|
||||
// ### Content Type
|
||||
// Ensure that the server responds with a 'content-type' definition. We should
|
||||
// always expect the server to respond with a type.
|
||||
|
||||
if (type === null) {
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: {
|
||||
status: response.status,
|
||||
message: "Missing 'content-type' in header returned from server.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ### Empty Response
|
||||
// If the response comes back with empty response status 204 we simply return a
|
||||
// empty success.
|
||||
|
||||
if (response.status === 204) {
|
||||
return {
|
||||
result: "success",
|
||||
headers: response.headers,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ### SCIM
|
||||
// If the 'content-type' is of type 'scim' we need to convert the SCIM compliant
|
||||
// response to a valid relay response.
|
||||
|
||||
if (type === "application/scim+json") {
|
||||
const parsed = await response.json();
|
||||
if (response.status >= 400) {
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: {
|
||||
status: response.status,
|
||||
message: parsed.detail,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: "success",
|
||||
headers: response.headers,
|
||||
data: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
// ### JSON
|
||||
// If the 'content-type' contains 'json' we treat it as a 'json' compliant response
|
||||
// and attempt to resolve it as such.
|
||||
|
||||
if (type.includes("json") === true) {
|
||||
const parsed = await response.json();
|
||||
if ("data" in parsed) {
|
||||
return {
|
||||
result: "success",
|
||||
headers: response.headers,
|
||||
data: parsed.data,
|
||||
};
|
||||
}
|
||||
if ("error" in parsed) {
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: this.#toError(parsed),
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: {
|
||||
status: response.status,
|
||||
message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: {
|
||||
status: response.status,
|
||||
message: "Unsupported 'content-type' in header returned from server.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] {
|
||||
if (assertServerErrorResponse(candidate)) {
|
||||
return ServerError.fromJSON({ type: "relay", ...candidate.error });
|
||||
}
|
||||
if (typeof candidate === "string") {
|
||||
return {
|
||||
status,
|
||||
message: candidate,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status,
|
||||
message: "Unsupported 'error' returned from server.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type HttpAdapterOptions = {
|
||||
url: string;
|
||||
hooks?: {
|
||||
beforeRequest?: ((headers: Headers) => Promise<void>)[];
|
||||
};
|
||||
};
|
||||
1
apps/react/src/assets/react.svg
Normal file
1
apps/react/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
9
apps/react/src/components/Session.tsx
Normal file
9
apps/react/src/components/Session.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { makeControllerView } from "../libraries/view.ts";
|
||||
import { SessionController } from "./session.controller.ts";
|
||||
|
||||
export const Session = makeControllerView(SessionController, ({ state: { error } }) => {
|
||||
if (error !== undefined) {
|
||||
return "Failed to fetch session";
|
||||
}
|
||||
return <div>Session OK!</div>;
|
||||
});
|
||||
24
apps/react/src/components/session.controller.ts
Normal file
24
apps/react/src/components/session.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Controller } from "../libraries/controller.ts";
|
||||
import { api } from "../services/api.ts";
|
||||
|
||||
export class SessionController extends Controller<{
|
||||
error?: string;
|
||||
}> {
|
||||
async onInit() {
|
||||
await this.getSessionCookie();
|
||||
}
|
||||
|
||||
async getSessionCookie() {
|
||||
const response = await api.auth.authenticate({
|
||||
body: {
|
||||
type: "email",
|
||||
payload: {
|
||||
email: "john.doe@fixture.none",
|
||||
},
|
||||
},
|
||||
});
|
||||
if ("error" in response) {
|
||||
this.setState("error", undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
apps/react/src/index.css
Normal file
68
apps/react/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user