diff --git a/examples/http-server/.envrc b/examples/http-server/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/examples/http-server/.envrc @@ -0,0 +1 @@ +use flake diff --git a/examples/http-server/.prettierignore b/examples/http-server/.prettierignore deleted file mode 100644 index e727c7d..0000000 --- a/examples/http-server/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -*.js -*.ts -*.cjs diff --git a/examples/http-server/.vscode/settings.json b/examples/http-server/.vscode/settings.json new file mode 100644 index 0000000..55712c1 --- /dev/null +++ b/examples/http-server/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/examples/http-server/eslint.config.mjs b/examples/http-server/eslint.config.mjs deleted file mode 100644 index 90e2807..0000000 --- a/examples/http-server/eslint.config.mjs +++ /dev/null @@ -1,122 +0,0 @@ -import { fixupPluginRules } from "@eslint/compat" -import { FlatCompat } from "@eslint/eslintrc" -import js from "@eslint/js" -import tsParser from "@typescript-eslint/parser" -import codegen from "eslint-plugin-codegen" -import deprecation from "eslint-plugin-deprecation" -import _import from "eslint-plugin-import" -import simpleImportSort from "eslint-plugin-simple-import-sort" -import sortDestructureKeys from "eslint-plugin-sort-destructure-keys" -import path from "node:path" -import { fileURLToPath } from "node:url" - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}) - -export default [ - { - ignores: ["**/dist", "**/build", "**/docs", "**/*.md"] - }, - ...compat.extends( - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@effect/recommended" - ), - { - plugins: { - deprecation, - import: fixupPluginRules(_import), - "sort-destructure-keys": sortDestructureKeys, - "simple-import-sort": simpleImportSort, - codegen - }, - - languageOptions: { - parser: tsParser, - ecmaVersion: 2018, - sourceType: "module" - }, - - settings: { - "import/parsers": { - "@typescript-eslint/parser": [".ts", ".tsx"] - }, - - "import/resolver": { - typescript: { - alwaysTryTypes: true - } - } - }, - - rules: { - "codegen/codegen": "error", - "no-fallthrough": "off", - "no-irregular-whitespace": "off", - "object-shorthand": "error", - "prefer-destructuring": "off", - "sort-imports": "off", - - "no-restricted-syntax": ["error", { - selector: "CallExpression[callee.property.name='push'] > SpreadElement.arguments", - message: "Do not use spread arguments in Array.push" - }], - - "no-unused-vars": "off", - "prefer-rest-params": "off", - "prefer-spread": "off", - "import/first": "error", - "import/newline-after-import": "error", - "import/no-duplicates": "error", - "import/no-unresolved": "off", - "import/order": "off", - "simple-import-sort/imports": "off", - "sort-destructure-keys/sort-destructure-keys": "error", - "deprecation/deprecation": "off", - - "@typescript-eslint/array-type": ["warn", { - default: "generic", - readonly: "generic" - }], - - "@typescript-eslint/member-delimiter-style": 0, - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-empty-interface": "off", - "@typescript-eslint/consistent-type-imports": "warn", - - "@typescript-eslint/no-unused-vars": ["error", { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_" - }], - - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/camelcase": "off", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/interface-name-prefix": "off", - "@typescript-eslint/no-array-constructor": "off", - "@typescript-eslint/no-use-before-define": "off", - "@typescript-eslint/no-namespace": "off", - - "@effect/dprint": ["error", { - config: { - indentWidth: 2, - lineWidth: 120, - semiColons: "asi", - quoteStyle: "alwaysDouble", - trailingCommas: "never", - operatorPosition: "maintain", - "arrowFunction.useParentheses": "force" - } - }] - } - } -] diff --git a/examples/http-server/flake.lock b/examples/http-server/flake.lock deleted file mode 100644 index bf19ce7..0000000 --- a/examples/http-server/flake.lock +++ /dev/null @@ -1,90 +0,0 @@ -{ - "nodes": { - "flake-parts": { - "inputs": { - "nixpkgs-lib": "nixpkgs-lib" - }, - "locked": { - "lastModified": 1722555600, - "narHash": "sha256-XOQkdLafnb/p9ij77byFQjDf5m5QYl9b2REiVClC+x4=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "8471fe90ad337a8074e957b69ca4d0089218391d", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "flake-parts", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1724395761, - "narHash": "sha256-zRkDV/nbrnp3Y8oCADf5ETl1sDrdmAW6/bBVJ8EbIdQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "ae815cee91b417be55d43781eb4b73ae1ecc396c", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-lib": { - "locked": { - "lastModified": 1722555339, - "narHash": "sha256-uFf2QeW7eAHlYXuDktm9c25OxOyCoUOQmh5SZ9amE5Q=", - "type": "tarball", - "url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz" - }, - "original": { - "type": "tarball", - "url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz" - } - }, - "process-compose-flake": { - "locked": { - "lastModified": 1724606023, - "narHash": "sha256-rdGeNa/lCS8E1lXzPqgl+vZUUvnbEZT11Bqkx5jfYug=", - "owner": "Platonic-Systems", - "repo": "process-compose-flake", - "rev": "f6ce9481df9aec739e4e06b67492401a5bb4f0b1", - "type": "github" - }, - "original": { - "owner": "Platonic-Systems", - "repo": "process-compose-flake", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs", - "process-compose-flake": "process-compose-flake", - "services-flake": "services-flake" - } - }, - "services-flake": { - "locked": { - "lastModified": 1724545750, - "narHash": "sha256-NF5VjVYBtMN3Cn2rjhuLe4iwsj3tFytNG6ARJZnhIyA=", - "owner": "juspay", - "repo": "services-flake", - "rev": "84fb5267fed8decd53a1deb8faccafc228623c8a", - "type": "github" - }, - "original": { - "owner": "juspay", - "repo": "services-flake", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/examples/http-server/flake.nix b/examples/http-server/flake.nix index 39e58e5..00cc7ca 100644 --- a/examples/http-server/flake.nix +++ b/examples/http-server/flake.nix @@ -5,15 +5,12 @@ process-compose-flake.url = "github:Platonic-Systems/process-compose-flake"; services-flake.url = "github:juspay/services-flake"; }; - outputs = inputs @ {flake-parts, ...}: flake-parts.lib.mkFlake {inherit inputs;} { systems = inputs.nixpkgs.lib.systems.flakeExposed; - imports = [ inputs.process-compose-flake.flakeModule ]; - perSystem = {pkgs, ...}: { devShells.default = pkgs.mkShell { nativeBuildInputs = with pkgs; [ @@ -27,31 +24,26 @@ inputs.services-flake.processComposeModules.default ]; - services = { - tempo.tempo.enable = true; - - redis.redis.enable = true; - - grafana.grafana = { - enable = true; - http_port = 4000; - extraConf = { - "auth.anonymous" = { - enabled = true; - org_role = "Editor"; - }; + services.tempo.tempo.enable = true; + services.grafana.grafana = { + enable = true; + http_port = 4000; + extraConf = { + "auth.anonymous" = { + enabled = true; + org_role = "Editor"; }; - datasources = with config.services.tempo.tempo; [ - { - name = "Tempo"; - type = "tempo"; - access = "proxy"; - url = "http://${httpAddress}:${builtins.toString httpPort}"; - } - ]; }; + datasources = with config.services.tempo.tempo; [ + { + name = "Tempo"; + type = "tempo"; + access = "proxy"; + url = "http://${httpAddress}:${builtins.toString httpPort}"; + } + ]; }; - + services.redis.redis.enable = true; settings.processes.tsx = { command = "pnpm dev"; }; diff --git a/examples/http-server/package.json b/examples/http-server/package.json index 58e3c3b..b36cb07 100644 --- a/examples/http-server/package.json +++ b/examples/http-server/package.json @@ -1,49 +1,38 @@ { "name": "http-server", - "version": "0.0.0", + "version": "1.0.0", "type": "module", + "description": "", "scripts": { - "check": "tsc -b tsconfig.json", "dev": "tsx --env-file=.env --watch src/main.ts", "test": "vitest" }, - "packageManager": "pnpm@9.9.0", - "dependencies": { - "@effect/experimental": "^0.24.0", - "@effect/opentelemetry": "^0.36.0", - "@effect/platform": "^0.63.0", - "@effect/platform-node": "^0.58.0", - "@effect/schema": "^0.72.0", - "@effect/sql": "^0.10.0", - "@effect/sql-sqlite-node": "^0.10.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", - "@opentelemetry/sdk-trace-base": "^1.26.0", - "@opentelemetry/sdk-trace-node": "^1.26.0", - "effect": "^3.7.2", - "uuid": "^10.0.0" - }, + "keywords": [], + "author": "", + "packageManager": "pnpm@9.7.0", "devDependencies": { - "@effect/eslint-plugin": "^0.2.0", "@effect/language-service": "^0.1.0", - "@effect/vitest": "^0.9.2", - "@eslint/compat": "^1.1.1", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "^9.9.1", + "@effect/vitest": "^0.9.1", "@types/node": "^22.5.3", "@types/uuid": "^10.0.0", - "@typescript-eslint/eslint-plugin": "^8.4.0", - "@typescript-eslint/parser": "^8.4.0", - "eslint": "^9.9.1", - "eslint-import-resolver-typescript": "^3.6.3", - "eslint-plugin-codegen": "^0.28.0", - "eslint-plugin-deprecation": "^3.0.0", - "eslint-plugin-import": "^2.30.0", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-sort-destructure-keys": "^2.0.0", "prettier": "^3.3.3", "tsup": "^8.2.4", "tsx": "^4.19.0", "typescript": "^5.5.4", "vitest": "^2.0.5" + }, + "dependencies": { + "@effect/experimental": "^0.24.1", + "@effect/opentelemetry": "^0.36.1", + "@effect/platform": "^0.63.1", + "@effect/platform-node": "^0.58.1", + "@effect/schema": "^0.72.1", + "@effect/sql": "^0.10.1", + "@effect/sql-sqlite-node": "^0.10.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", + "@opentelemetry/sdk-trace-base": "^1.26.0", + "@opentelemetry/sdk-trace-node": "^1.26.0", + "effect": "^3.7.1", + "uuid": "^10.0.0" } } diff --git a/examples/http-server/src/Accounts.ts b/examples/http-server/src/Accounts.ts index 10ac856..943b268 100644 --- a/examples/http-server/src/Accounts.ts +++ b/examples/http-server/src/Accounts.ts @@ -1,17 +1,27 @@ -import { HttpApiBuilder } from "@effect/platform" import { SqlClient } from "@effect/sql" import { Effect, Layer, Option, pipe } from "effect" import { AccountsRepo } from "./Accounts/AccountsRepo.js" import { UsersRepo } from "./Accounts/UsersRepo.js" -import type { AccessToken } from "./Domain/AccessToken.js" -import { accessTokenFromRedacted, accessTokenFromString } from "./Domain/AccessToken.js" +import { + AccessToken, + accessTokenFromRedacted, + accessTokenFromString, +} from "./Domain/AccessToken.js" import { Account } from "./Domain/Account.js" import { policyRequire, Unauthorized } from "./Domain/Policy.js" -import { CurrentUser, User, UserId, UserNotFound, UserWithSensitive } from "./Domain/User.js" +import { + CurrentUser, + User, + UserId, + UserNotFound, + UserWithSensitive, +} from "./Domain/User.js" import { SqlLive, SqlTest } from "./Sql.js" import { Uuid } from "./Uuid.js" +import { HttpApiBuilder } from "@effect/platform" +import { security } from "./Api/Security.js" -const make = Effect.gen(function*() { +const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient const accountRepo = yield* AccountsRepo const userRepo = yield* UsersRepo @@ -21,26 +31,29 @@ const make = Effect.gen(function*() { accountRepo.insert(Account.insert.make({})).pipe( Effect.tap((account) => Effect.annotateCurrentSpan("account", account)), Effect.bindTo("account"), - Effect.bind("accessToken", () => uuid.generate.pipe(Effect.map(accessTokenFromString))), - Effect.bind("user", ({ accessToken, account }) => + Effect.bind("accessToken", () => + uuid.generate.pipe(Effect.map(accessTokenFromString)), + ), + Effect.bind("user", ({ account, accessToken }) => userRepo.insert( User.insert.make({ ...user, accountId: account.id, - accessToken - }) - )), + accessToken, + }), + ), + ), Effect.map( - ({ account, user }) => + ({ user, account }) => new UserWithSensitive({ ...user, - account - }) + account, + }), ), sql.withTransaction, Effect.orDie, Effect.withSpan("Accounts.createUser", { attributes: { user } }), - policyRequire("User", "create") + policyRequire("User", "create"), ) const updateUser = (id: UserId, user: Partial) => @@ -48,37 +61,37 @@ const make = Effect.gen(function*() { Effect.flatMap( Option.match({ onNone: () => new UserNotFound({ id }), - onSome: Effect.succeed - }) + onSome: Effect.succeed, + }), ), Effect.andThen((previous) => userRepo.update({ ...previous, ...user, id, - updatedAt: undefined - }) + updatedAt: undefined, + }), ), sql.withTransaction, Effect.catchTag("SqlError", (err) => Effect.die(err)), Effect.withSpan("Accounts.updateUser", { attributes: { id, user } }), - policyRequire("User", "update") + policyRequire("User", "update"), ) const findUserByAccessToken = (apiKey: AccessToken) => pipe( userRepo.findByAccessToken(apiKey), Effect.withSpan("Accounts.findUserByAccessToken"), - policyRequire("User", "read") + policyRequire("User", "read"), ) const findUserById = (id: UserId) => pipe( userRepo.findById(id), Effect.withSpan("Accounts.findUserById", { - attributes: { id } + attributes: { id }, }), - policyRequire("User", "read") + policyRequire("User", "read"), ) const embellishUser = (user: User) => @@ -88,9 +101,9 @@ const make = Effect.gen(function*() { Effect.map((account) => new UserWithSensitive({ ...user, account })), Effect.orDie, Effect.withSpan("Accounts.embellishUser", { - attributes: { id: user.id } + attributes: { id: user.id }, }), - policyRequire("User", "readSensitive") + policyRequire("User", "readSensitive"), ) const httpSecurity = HttpApiBuilder.middlewareSecurity( @@ -104,13 +117,13 @@ const make = Effect.gen(function*() { new Unauthorized({ actorId: UserId.make(-1), entity: "User", - action: "read" + action: "read", }), - onSome: Effect.succeed - }) + onSome: Effect.succeed, + }), ), - Effect.withSpan("Accounts.httpSecurity") - ) + Effect.withSpan("Accounts.httpSecurity"), + ), ) return { @@ -119,7 +132,7 @@ const make = Effect.gen(function*() { findUserByAccessToken, findUserById, embellishUser, - httpSecurity + httpSecurity, } as const }) @@ -133,11 +146,11 @@ export class Accounts extends Effect.Tag("Accounts")< Layer.provide(SqlLive), Layer.provide(AccountsRepo.Live), Layer.provide(UsersRepo.Live), - Layer.provide(Uuid.Live) + Layer.provide(Uuid.Live), ) static Test = this.layer.pipe( Layer.provideMerge(SqlTest), - Layer.provideMerge(Uuid.Test) + Layer.provideMerge(Uuid.Test), ) } diff --git a/examples/http-server/src/Accounts/AccountsRepo.ts b/examples/http-server/src/Accounts/AccountsRepo.ts index 915e314..dab0abc 100644 --- a/examples/http-server/src/Accounts/AccountsRepo.ts +++ b/examples/http-server/src/Accounts/AccountsRepo.ts @@ -1,14 +1,13 @@ import { Model } from "@effect/sql" -import type { Effect } from "effect" -import { Context, Layer } from "effect" +import { Context, Effect, Layer } from "effect" import { Account } from "../Domain/Account.js" -import { makeTestLayer } from "../lib/Layer.js" import { SqlLive } from "../Sql.js" +import { makeTestLayer } from "../lib/Layer.js" export const make = Model.makeRepository(Account, { tableName: "accounts", spanPrefix: "AccountsRepo", - idColumn: "id" + idColumn: "id", }) export class AccountsRepo extends Context.Tag("Accounts/AccountsRepo")< diff --git a/examples/http-server/src/Api/Accounts.ts b/examples/http-server/src/Accounts/Api.ts similarity index 71% rename from examples/http-server/src/Api/Accounts.ts rename to examples/http-server/src/Accounts/Api.ts index ae3fb46..c9b5bbb 100644 --- a/examples/http-server/src/Api/Accounts.ts +++ b/examples/http-server/src/Accounts/Api.ts @@ -1,8 +1,13 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" import { Schema } from "@effect/schema" +import { security } from "../Api/Security.js" import { Unauthorized } from "../Domain/Policy.js" -import { User, UserIdFromString, UserNotFound, UserWithSensitive } from "../Domain/User.js" -import { security } from "./Security.js" +import { + User, + UserIdFromString, + UserNotFound, + UserWithSensitive, +} from "../Domain/User.js" export class AccountsApi extends HttpApiGroup.make("accounts").pipe( HttpApiGroup.add( @@ -11,21 +16,21 @@ export class AccountsApi extends HttpApiGroup.make("accounts").pipe( HttpApiEndpoint.setSuccess(User.json), HttpApiEndpoint.addError(UserNotFound), HttpApiEndpoint.setPayload( - Schema.partialWith(User.jsonUpdate, { exact: true }) - ) - ) + Schema.partialWith(User.jsonUpdate, { exact: true }), + ), + ), ), HttpApiGroup.add( HttpApiEndpoint.get("getUserMe", "/users/me").pipe( - HttpApiEndpoint.setSuccess(UserWithSensitive.json) - ) + HttpApiEndpoint.setSuccess(UserWithSensitive.json), + ), ), HttpApiGroup.add( HttpApiEndpoint.get("getUser", "/users/:id").pipe( HttpApiEndpoint.setPath(Schema.Struct({ id: UserIdFromString })), HttpApiEndpoint.setSuccess(User.json), - HttpApiEndpoint.addError(UserNotFound) - ) + HttpApiEndpoint.addError(UserNotFound), + ), ), HttpApiGroup.annotateEndpoints(OpenApi.Security, security), HttpApiGroup.addError(Unauthorized), @@ -33,11 +38,11 @@ export class AccountsApi extends HttpApiGroup.make("accounts").pipe( HttpApiGroup.add( HttpApiEndpoint.post("createUser", "/users").pipe( HttpApiEndpoint.setSuccess(UserWithSensitive.json), - HttpApiEndpoint.setPayload(User.jsonCreate) - ) + HttpApiEndpoint.setPayload(User.jsonCreate), + ), ), OpenApi.annotate({ title: "Accounts", - description: "Manage user accounts" - }) + description: "Manage user accounts", + }), ) {} diff --git a/examples/http-server/src/Accounts/Http.ts b/examples/http-server/src/Accounts/Http.ts index d4fbc65..f64a7e3 100644 --- a/examples/http-server/src/Accounts/Http.ts +++ b/examples/http-server/src/Accounts/Http.ts @@ -2,48 +2,54 @@ import { HttpApiBuilder } from "@effect/platform" import { Effect, Layer, Option, pipe } from "effect" import { Accounts } from "../Accounts.js" import { Api } from "../Api.js" -import { security } from "../Api/Security.js" import { policyUse, withSystemActor } from "../Domain/Policy.js" import { CurrentUser, UserNotFound } from "../Domain/User.js" import { AccountsPolicy } from "./Policy.js" +import { security } from "../Api/Security.js" export const HttpAccountsLive = HttpApiBuilder.group( Api, "accounts", (handlers) => - Effect.gen(function*() { + Effect.gen(function* () { const accounts = yield* Accounts const policy = yield* AccountsPolicy return handlers.pipe( - HttpApiBuilder.handle("updateUser", ({ path, payload }) => + HttpApiBuilder.handle("updateUser", ({ payload, path }) => pipe( accounts.updateUser(path.id, payload), - policyUse(policy.canUpdate(path.id)) - )), + policyUse(policy.canUpdate(path.id)), + ), + ), HttpApiBuilder.handle("getUserMe", () => CurrentUser.pipe( Effect.flatMap(accounts.embellishUser), - withSystemActor - )), + withSystemActor, + ), + ), HttpApiBuilder.handle("getUser", ({ path }) => pipe( accounts.findUserById(path.id), Effect.flatMap( Option.match({ onNone: () => new UserNotFound({ id: path.id }), - onSome: Effect.succeed - }) + onSome: Effect.succeed, + }), ), - policyUse(policy.canRead(path.id)) - )), + policyUse(policy.canRead(path.id)), + ), + ), accounts.httpSecurity, // unprotected HttpApiBuilder.handle("createUser", ({ payload }) => accounts.createUser(payload).pipe( withSystemActor, - Effect.tap((user) => HttpApiBuilder.securitySetCookie(security, user.accessToken)) - )) + Effect.tap((user) => + HttpApiBuilder.securitySetCookie(security, user.accessToken), + ), + ), + ), ) - }) + }), ).pipe(Layer.provide(Accounts.Live), Layer.provide(AccountsPolicy.Live)) diff --git a/examples/http-server/src/Accounts/Policy.ts b/examples/http-server/src/Accounts/Policy.ts index 7108a7e..81c202d 100644 --- a/examples/http-server/src/Accounts/Policy.ts +++ b/examples/http-server/src/Accounts/Policy.ts @@ -1,15 +1,18 @@ import { Effect, Layer } from "effect" +import { UserId } from "../Domain/User.js" import { policy } from "../Domain/Policy.js" -import type { UserId } from "../Domain/User.js" -// eslint-disable-next-line require-yield -const make = Effect.gen(function*() { - const canUpdate = (toUpdate: UserId) => policy("User", "update", (actor) => Effect.succeed(actor.id === toUpdate)) +const make = Effect.gen(function* () { + const canUpdate = (toUpdate: UserId) => + policy("User", "update", (actor) => Effect.succeed(actor.id === toUpdate)) - const canRead = (toRead: UserId) => policy("User", "read", (actor) => Effect.succeed(actor.id === toRead)) + const canRead = (toRead: UserId) => + policy("User", "read", (actor) => Effect.succeed(actor.id === toRead)) const canReadSensitive = (toRead: UserId) => - policy("User", "readSensitive", (actor) => Effect.succeed(actor.id === toRead)) + policy("User", "readSensitive", (actor) => + Effect.succeed(actor.id === toRead), + ) return { canUpdate, canRead, canReadSensitive } as const }) diff --git a/examples/http-server/src/Accounts/UsersRepo.ts b/examples/http-server/src/Accounts/UsersRepo.ts index 19e3324..aa27e98 100644 --- a/examples/http-server/src/Accounts/UsersRepo.ts +++ b/examples/http-server/src/Accounts/UsersRepo.ts @@ -1,28 +1,28 @@ import { Model, SqlClient, SqlSchema } from "@effect/sql" import { Effect, Layer, pipe } from "effect" -import { AccessToken } from "../Domain/AccessToken.js" import { User } from "../Domain/User.js" -import { makeTestLayer } from "../lib/Layer.js" import { SqlLive } from "../Sql.js" +import { AccessToken } from "../Domain/AccessToken.js" +import { makeTestLayer } from "../lib/Layer.js" -export const make = Effect.gen(function*() { +export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient const repo = yield* Model.makeRepository(User, { tableName: "users", spanPrefix: "UsersRepo", - idColumn: "id" + idColumn: "id", }) const findByAccessTokenSchema = SqlSchema.findOne({ Request: AccessToken, Result: User, - execute: (key) => sql`select * from users where accessToken = ${key}` + execute: (key) => sql`select * from users where accessToken = ${key}`, }) const findByAccessToken = (apiKey: AccessToken) => pipe( findByAccessTokenSchema(apiKey), Effect.orDie, - Effect.withSpan("UsersRepo.findByAccessToken") + Effect.withSpan("UsersRepo.findByAccessToken"), ) return { ...repo, findByAccessToken } as const diff --git a/examples/http-server/src/Api.ts b/examples/http-server/src/Api.ts index 3cc7243..fc489c2 100644 --- a/examples/http-server/src/Api.ts +++ b/examples/http-server/src/Api.ts @@ -1,11 +1,11 @@ import { HttpApi, OpenApi } from "@effect/platform" -import { AccountsApi } from "./Api/Accounts.js" -import { GroupsApi } from "./Api/Groups.js" -import { PeopleApi } from "./Api/People.js" +import { AccountsApi } from "./Accounts/Api.js" +import { GroupsApi } from "./Groups/Api.js" +import { PeopleApi } from "./People/Api.js" export class Api extends HttpApi.empty.pipe( HttpApi.addGroup(AccountsApi), HttpApi.addGroup(GroupsApi), HttpApi.addGroup(PeopleApi), - OpenApi.annotate({ title: "Groups API" }) + OpenApi.annotate({ title: "Groups API" }), ) {} diff --git a/examples/http-server/src/Api/Security.ts b/examples/http-server/src/Api/Security.ts index 677adb7..5b1cd3f 100644 --- a/examples/http-server/src/Api/Security.ts +++ b/examples/http-server/src/Api/Security.ts @@ -2,5 +2,5 @@ import { HttpApiSecurity } from "@effect/platform" export const security = HttpApiSecurity.apiKey({ in: "cookie", - key: "token" + key: "token", }) diff --git a/examples/http-server/src/Domain/AccessToken.ts b/examples/http-server/src/Domain/AccessToken.ts index 8760f6f..7189657 100644 --- a/examples/http-server/src/Domain/AccessToken.ts +++ b/examples/http-server/src/Domain/AccessToken.ts @@ -5,8 +5,9 @@ export const AccessTokenString = Schema.String.pipe(Schema.brand("AccessToken")) export const AccessToken = Schema.Redacted(AccessTokenString) export type AccessToken = typeof AccessToken.Type -export const accessTokenFromString = (token: string): AccessToken => Redacted.make(AccessTokenString.make(token)) +export const accessTokenFromString = (token: string): AccessToken => + Redacted.make(AccessTokenString.make(token)) export const accessTokenFromRedacted = ( - token: Redacted.Redacted + token: Redacted.Redacted, ): AccessToken => token as AccessToken diff --git a/examples/http-server/src/Domain/Account.ts b/examples/http-server/src/Domain/Account.ts index f891291..1b472ec 100644 --- a/examples/http-server/src/Domain/Account.ts +++ b/examples/http-server/src/Domain/Account.ts @@ -7,5 +7,5 @@ export type AccountId = typeof AccountId.Type export class Account extends Model.Class("Account")({ id: Model.Generated(AccountId), createdAt: Model.DateTimeInsert, - updatedAt: Model.DateTimeUpdate + updatedAt: Model.DateTimeUpdate, }) {} diff --git a/examples/http-server/src/Domain/Email.ts b/examples/http-server/src/Domain/Email.ts index 099254e..73cd95d 100644 --- a/examples/http-server/src/Domain/Email.ts +++ b/examples/http-server/src/Domain/Email.ts @@ -4,10 +4,10 @@ export const Email = Schema.String.pipe( Schema.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/), Schema.annotations({ title: "Email", - description: "An email address" + description: "An email address", }), Schema.brand("Email"), - Schema.annotations({ title: "Email" }) + Schema.annotations({ title: "Email" }), ) export type Email = typeof Email.Type diff --git a/examples/http-server/src/Domain/Group.ts b/examples/http-server/src/Domain/Group.ts index 264977a..df78cc6 100644 --- a/examples/http-server/src/Domain/Group.ts +++ b/examples/http-server/src/Domain/Group.ts @@ -1,13 +1,13 @@ -import { HttpApiSchema } from "@effect/platform" import { Schema } from "@effect/schema" import { Model } from "@effect/sql" import { AccountId } from "./Account.js" +import { HttpApiSchema } from "@effect/platform" export const GroupId = Schema.Number.pipe(Schema.brand("GroupId")) export type GroupId = typeof GroupId.Type export const GroupIdFromString = Schema.NumberFromString.pipe( - Schema.compose(GroupId) + Schema.compose(GroupId), ) export class Group extends Model.Class("Group")({ @@ -15,11 +15,11 @@ export class Group extends Model.Class("Group")({ ownerId: Model.GeneratedByApp(AccountId), name: Schema.NonEmptyTrimmedString, createdAt: Model.DateTimeInsert, - updatedAt: Model.DateTimeUpdate + updatedAt: Model.DateTimeUpdate, }) {} export class GroupNotFound extends Schema.TaggedError()( "GroupNotFound", { id: GroupId }, - HttpApiSchema.annotations({ status: 404 }) + HttpApiSchema.annotations({ status: 404 }), ) {} diff --git a/examples/http-server/src/Domain/Person.ts b/examples/http-server/src/Domain/Person.ts index ca45281..4f70793 100644 --- a/examples/http-server/src/Domain/Person.ts +++ b/examples/http-server/src/Domain/Person.ts @@ -5,6 +5,10 @@ import { GroupId } from "./Group.js" export const PersonId = Schema.Number.pipe(Schema.brand("PersonId")) export type PersonId = typeof PersonId.Type +export const PersonIdFromString = Schema.NumberFromString.pipe( + Schema.compose(PersonId), +) + export class Person extends Model.Class("Person")({ id: Model.Generated(PersonId), groupId: Model.GeneratedByApp(GroupId), @@ -12,5 +16,9 @@ export class Person extends Model.Class("Person")({ lastName: Schema.NonEmptyTrimmedString, dateOfBirth: Model.FieldOption(Model.Date), createdAt: Model.DateTimeInsert, - updatedAt: Model.DateTimeUpdate + updatedAt: Model.DateTimeUpdate, }) {} + +export class PersonNotFound extends Schema.TaggedError()("PersonNotFound", { + id: PersonId, +}) {} \ No newline at end of file diff --git a/examples/http-server/src/Domain/Policy.ts b/examples/http-server/src/Domain/Policy.ts index 81f8998..cb35d8e 100644 --- a/examples/http-server/src/Domain/Policy.ts +++ b/examples/http-server/src/Domain/Policy.ts @@ -1,39 +1,64 @@ -import { HttpApiSchema } from "@effect/platform" +import { Effect, Predicate } from "effect" +import { CurrentUser, User, UserId } from "../Domain/User.js" import { Schema } from "@effect/schema" -import { Effect } from "effect" -import type { User } from "../Domain/User.js" -import { CurrentUser, UserId } from "../Domain/User.js" +import { HttpApiSchema } from "@effect/platform" export class Unauthorized extends Schema.TaggedError()( "Unauthorized", { actorId: UserId, entity: Schema.String, - action: Schema.String + action: Schema.String, }, - HttpApiSchema.annotations({ status: 403 }) + HttpApiSchema.annotations({ status: 403 }), ) { get message() { return `Actor (${this.actorId}) is not authorized to perform action "${this.action}" on entity "${this.entity}"` } + + static is(u: unknown): u is Unauthorized { + return Predicate.isTagged(u, "Unauthorized") + } + + static refail(entity: string, action: string) { + return ( + effect: Effect.Effect, + ): Effect.Effect => + Effect.catchIf( + effect, + (e) => !Unauthorized.is(e), + () => + Effect.flatMap( + CurrentUser, + (actor) => + new Unauthorized({ + actorId: actor.id, + entity, + action, + }), + ), + ) as any + } } export const TypeId: unique symbol = Symbol.for("Domain/Policy/AuthorizedActor") export type TypeId = typeof TypeId -export interface AuthorizedActor extends User { +export interface AuthorizedActor + extends User { readonly [TypeId]: { readonly _Entity: Entity readonly _Action: Action } } -export const authorizedActor = (user: User): AuthorizedActor => user as any +export const authorizedActor = (user: User): AuthorizedActor => + user as any export const policy = ( entity: Entity, action: Action, - f: (actor: User) => Effect.Effect + f: (actor: User) => Effect.Effect, ): Effect.Effect< AuthorizedActor, E | Unauthorized, @@ -44,35 +69,43 @@ export const policy = ( can ? Effect.succeed(authorizedActor(actor)) : Effect.fail( - new Unauthorized({ - actorId: actor.id, - entity, - action - }) - ))) + new Unauthorized({ + actorId: actor.id, + entity, + action, + }), + ), + ), + ) -export const policyCompose = , E, R>( - that: Effect.Effect -) => -, E2, R2>( - self: Effect.Effect -): Effect.Effect => Effect.zipRight(self, that) as any +export const policyCompose = + , E, R>( + that: Effect.Effect, + ) => + , E2, R2>( + self: Effect.Effect, + ): Effect.Effect => + Effect.zipRight(self, that) as any -export const policyUse = , E, R>( - policy: Effect.Effect -) => -( - effect: Effect.Effect -): Effect.Effect | R> => policy.pipe(Effect.zipRight(effect)) as any +export const policyUse = + , E, R>( + policy: Effect.Effect, + ) => + ( + effect: Effect.Effect, + ): Effect.Effect | R> => + policy.pipe(Effect.zipRight(effect)) as any -export const policyRequire = ( - _entity: Entity, - _action: Action -) => -( - effect: Effect.Effect -): Effect.Effect> => effect +export const policyRequire = + ( + _entity: Entity, + _action: Action, + ) => + ( + effect: Effect.Effect, + ): Effect.Effect> => + effect export const withSystemActor = ( - effect: Effect.Effect + effect: Effect.Effect, ): Effect.Effect>> => effect as any diff --git a/examples/http-server/src/Domain/User.ts b/examples/http-server/src/Domain/User.ts index 89af428..5b74868 100644 --- a/examples/http-server/src/Domain/User.ts +++ b/examples/http-server/src/Domain/User.ts @@ -1,16 +1,16 @@ -import { HttpApiSchema } from "@effect/platform" import { Schema } from "@effect/schema" import { Model } from "@effect/sql" import { Context } from "effect" import { AccessToken } from "./AccessToken.js" import { Account, AccountId } from "./Account.js" import { Email } from "./Email.js" +import { HttpApiSchema } from "@effect/platform" export const UserId = Schema.Number.pipe(Schema.brand("UserId")) export type UserId = typeof UserId.Type export const UserIdFromString = Schema.NumberFromString.pipe( - Schema.compose(UserId) + Schema.compose(UserId), ) export class User extends Model.Class("User")({ @@ -19,15 +19,15 @@ export class User extends Model.Class("User")({ email: Email, accessToken: Model.Sensitive(AccessToken), createdAt: Model.DateTimeInsert, - updatedAt: Model.DateTimeUpdate + updatedAt: Model.DateTimeUpdate, }) {} export class UserWithSensitive extends Model.Class( - "UserWithSensitive" + "UserWithSensitive", )({ ...Model.fields(User), accessToken: AccessToken, - account: Account + account: Account, }) {} export class CurrentUser extends Context.Tag("Domain/User/CurrentUser")< @@ -38,5 +38,5 @@ export class CurrentUser extends Context.Tag("Domain/User/CurrentUser")< export class UserNotFound extends Schema.TaggedError()( "UserNotFound", { id: UserId }, - HttpApiSchema.annotations({ status: 404 }) + HttpApiSchema.annotations({ status: 404 }), ) {} diff --git a/examples/http-server/src/Groups.ts b/examples/http-server/src/Groups.ts index d232ed3..206487e 100644 --- a/examples/http-server/src/Groups.ts +++ b/examples/http-server/src/Groups.ts @@ -1,13 +1,12 @@ -import { SqlClient } from "@effect/sql" import { Effect, Layer, Option, pipe } from "effect" -import type { AccountId } from "./Domain/Account.js" -import type { GroupId } from "./Domain/Group.js" -import { Group, GroupNotFound } from "./Domain/Group.js" -import { policyRequire } from "./Domain/Policy.js" import { GroupsRepo } from "./Groups/Repo.js" +import { AccountId } from "./Domain/Account.js" +import { Group, GroupId, GroupNotFound } from "./Domain/Group.js" +import { policyRequire } from "./Domain/Policy.js" +import { SqlClient } from "@effect/sql" import { SqlLive } from "./Sql.js" -const make = Effect.gen(function*() { +const make = Effect.gen(function* () { const repo = yield* GroupsRepo const sql = yield* SqlClient.SqlClient @@ -16,52 +15,52 @@ const make = Effect.gen(function*() { repo.insert( Group.insert.make({ ...group, - ownerId - }) + ownerId, + }), ), Effect.withSpan("Groups.create", { attributes: { group } }), - policyRequire("Group", "create") + policyRequire("Group", "create"), ) const update = ( group: Group, - update: Partial + update: Partial, ) => pipe( repo.update({ ...group, ...update, - updatedAt: undefined + updatedAt: undefined, }), Effect.withSpan("Groups.update", { - attributes: { id: group.id, update } + attributes: { id: group.id, update }, }), - policyRequire("Group", "update") + policyRequire("Group", "update"), ) const findById = (id: GroupId) => pipe( repo.findById(id), Effect.withSpan("Groups.findById", { attributes: { id } }), - policyRequire("Group", "read") + policyRequire("Group", "read"), ) const with_ = ( id: GroupId, - f: (group: Group) => Effect.Effect + f: (group: Group) => Effect.Effect, ): Effect.Effect => pipe( repo.findById(id), Effect.flatMap( Option.match({ onNone: () => new GroupNotFound({ id }), - onSome: Effect.succeed - }) + onSome: Effect.succeed, + }), ), Effect.flatMap(f), sql.withTransaction, Effect.catchTag("SqlError", (err) => Effect.die(err)), - Effect.withSpan("Groups.with", { attributes: { id } }) + Effect.withSpan("Groups.with", { attributes: { id } }), ) return { create, update, findById, with: with_ } as const @@ -74,14 +73,14 @@ export class Groups extends Effect.Tag("Groups")< static layer = Layer.effect(Groups, make) static Live = this.layer.pipe( Layer.provide(SqlLive), - Layer.provide(GroupsRepo.Live) + Layer.provide(GroupsRepo.Live), ) } export const withGroup = ( id: GroupId, - f: (group: Group, groups: typeof Groups.Service) => Effect.Effect + f: (group: Group, groups: typeof Groups.Service) => Effect.Effect, ) => Groups.pipe( - Effect.flatMap((groups) => groups.with(id, (group) => f(group, groups))) + Effect.flatMap((groups) => groups.with(id, (group) => f(group, groups))), ) diff --git a/examples/http-server/src/Api/Groups.ts b/examples/http-server/src/Groups/Api.ts similarity index 82% rename from examples/http-server/src/Api/Groups.ts rename to examples/http-server/src/Groups/Api.ts index 65e651f..87d3e6a 100644 --- a/examples/http-server/src/Api/Groups.ts +++ b/examples/http-server/src/Groups/Api.ts @@ -1,29 +1,29 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" -import { Schema } from "@effect/schema" import { Group, GroupIdFromString, GroupNotFound } from "../Domain/Group.js" +import { Schema } from "@effect/schema" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" import { Unauthorized } from "../Domain/Policy.js" -import { security } from "./Security.js" +import { security } from "../Api/Security.js" export class GroupsApi extends HttpApiGroup.make("groups").pipe( HttpApiGroup.add( HttpApiEndpoint.post("create", "/").pipe( HttpApiEndpoint.setSuccess(Group.json), - HttpApiEndpoint.setPayload(Group.jsonCreate) - ) + HttpApiEndpoint.setPayload(Group.jsonCreate), + ), ), HttpApiGroup.add( HttpApiEndpoint.patch("update", "/:id").pipe( HttpApiEndpoint.setPath(Schema.Struct({ id: GroupIdFromString })), HttpApiEndpoint.setSuccess(Group.json), HttpApiEndpoint.setPayload(Group.jsonUpdate), - HttpApiEndpoint.addError(GroupNotFound) - ) + HttpApiEndpoint.addError(GroupNotFound), + ), ), HttpApiGroup.prefix("/groups"), HttpApiGroup.addError(Unauthorized), OpenApi.annotate({ title: "Groups", description: "Manage groups", - security - }) + security, + }), ) {} diff --git a/examples/http-server/src/Groups/Http.ts b/examples/http-server/src/Groups/Http.ts index 742f0e2..93f89a8 100644 --- a/examples/http-server/src/Groups/Http.ts +++ b/examples/http-server/src/Groups/Http.ts @@ -8,7 +8,7 @@ import { Groups } from "../Groups.js" import { GroupsPolicy } from "./Policy.js" export const HttpGroupsLive = HttpApiBuilder.group(Api, "groups", (handlers) => - Effect.gen(function*() { + Effect.gen(function* () { const groups = yield* Groups const policy = yield* GroupsPolicy const accounts = yield* Accounts @@ -17,18 +17,22 @@ export const HttpGroupsLive = HttpApiBuilder.group(Api, "groups", (handlers) => HttpApiBuilder.handle("create", ({ payload }) => CurrentUser.pipe( Effect.flatMap((user) => groups.create(user.accountId, payload)), - policyUse(policy.canCreate(payload)) - )), - HttpApiBuilder.handle("update", ({ path, payload }) => + policyUse(policy.canCreate(payload)), + ), + ), + HttpApiBuilder.handle("update", ({ payload, path }) => groups.with(path.id, (group) => pipe( groups.update(group, payload), - policyUse(policy.canUpdate(group)) - ))), - accounts.httpSecurity + policyUse(policy.canUpdate(group)), + ), + ), + ), + accounts.httpSecurity, ) - })).pipe( - Layer.provide(Accounts.Live), - Layer.provide(Groups.Live), - Layer.provide(GroupsPolicy.Live) - ) + }), +).pipe( + Layer.provide(Accounts.Live), + Layer.provide(Groups.Live), + Layer.provide(GroupsPolicy.Live), +) diff --git a/examples/http-server/src/Groups/Policy.ts b/examples/http-server/src/Groups/Policy.ts index f4501c9..461f8dc 100644 --- a/examples/http-server/src/Groups/Policy.ts +++ b/examples/http-server/src/Groups/Policy.ts @@ -1,14 +1,15 @@ import { Effect, Layer } from "effect" -import type { Group } from "../Domain/Group.js" import { policy } from "../Domain/Policy.js" +import { Group } from "../Domain/Group.js" -// eslint-disable-next-line require-yield -const make = Effect.gen(function*() { +const make = Effect.gen(function* () { const canCreate = (_group: typeof Group.jsonCreate.Type) => policy("Group", "create", (_actor) => Effect.succeed(true)) const canUpdate = (group: Group) => - policy("Group", "update", (actor) => Effect.succeed(group.ownerId === actor.accountId)) + policy("Group", "update", (actor) => + Effect.succeed(group.ownerId === actor.accountId), + ) return { canCreate, canUpdate } as const }) diff --git a/examples/http-server/src/Groups/Repo.ts b/examples/http-server/src/Groups/Repo.ts index 6c7be4d..06730bb 100644 --- a/examples/http-server/src/Groups/Repo.ts +++ b/examples/http-server/src/Groups/Repo.ts @@ -1,13 +1,27 @@ import { Model } from "@effect/sql" -import type { Effect } from "effect" -import { Context, Layer } from "effect" -import { Group } from "../Domain/Group.js" +import { Cache, Context, Effect, Layer } from "effect" +import { Group, GroupId } from "../Domain/Group.js" import { SqlLive } from "../Sql.js" -const make = Model.makeRepository(Group, { - tableName: "groups", - spanPrefix: "GroupsRepo", - idColumn: "id" +const make = Effect.gen(function* () { + const repo = yield* Model.makeRepository(Group, { + tableName: "groups", + spanPrefix: "GroupsRepo", + idColumn: "id", + }) + + const findById = yield* Cache.make({ + lookup: repo.findById, + capacity: 1024, + timeToLive: 30_000, + }) + + return { + ...repo, + findById(id: GroupId) { + return findById.get(id) + }, + } }) export class GroupsRepo extends Context.Tag("Groups/Repo")< diff --git a/examples/http-server/src/Http.ts b/examples/http-server/src/Http.ts index 631081a..38d46fd 100644 --- a/examples/http-server/src/Http.ts +++ b/examples/http-server/src/Http.ts @@ -1,16 +1,21 @@ -import { HttpApiBuilder, HttpApiSwagger, HttpMiddleware, HttpServer } from "@effect/platform" -import { NodeHttpServer } from "@effect/platform-node" +import { + HttpApiBuilder, + HttpApiSwagger, + HttpMiddleware, + HttpServer, +} from "@effect/platform" import { Layer } from "effect" +import { NodeHttpServer } from "@effect/platform-node" import { createServer } from "http" -import { HttpAccountsLive } from "./Accounts/Http.js" import { Api } from "./Api.js" +import { HttpAccountsLive } from "./Accounts/Http.js" import { HttpGroupsLive } from "./Groups/Http.js" import { HttpPeopleLive } from "./People/Http.js" const ApiLive = HttpApiBuilder.api(Api).pipe( Layer.provide(HttpAccountsLive), Layer.provide(HttpGroupsLive), - Layer.provide(HttpPeopleLive) + Layer.provide(HttpPeopleLive), ) export const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe( @@ -18,5 +23,5 @@ export const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe( Layer.provide(HttpApiBuilder.middlewareCors()), Layer.provide(ApiLive), HttpServer.withLogAddress, - Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) + Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), ) diff --git a/examples/http-server/src/People.ts b/examples/http-server/src/People.ts index 1d0d5cf..ac92979 100644 --- a/examples/http-server/src/People.ts +++ b/examples/http-server/src/People.ts @@ -1,25 +1,53 @@ -import { Effect, Layer, pipe } from "effect" -import type { GroupId } from "./Domain/Group.js" -import { Person } from "./Domain/Person.js" +import { Effect, Layer, Option, pipe } from "effect" +import { PeopleRepo } from "./People/Repo.js" +import { Person, PersonId, PersonNotFound } from "./Domain/Person.js" import { policyRequire } from "./Domain/Policy.js" -import type { PeopleRepo } from "./People/Repo.js" +import { GroupId } from "./Domain/Group.js" +import { SqlClient, SqlError } from "@effect/sql" +import { SqlLive } from "./Sql.js" -const make = Effect.gen(function*() { +const make = Effect.gen(function* () { const repo = yield* PeopleRepo + const sql = yield* SqlClient.SqlClient const create = (groupId: GroupId, person: typeof Person.jsonCreate.Type) => pipe( repo.insert( Person.insert.make({ ...person, - groupId - }) + groupId, + }), ), Effect.withSpan("People.create", { attributes: { person, groupId } }), - policyRequire("Person", "create") + policyRequire("Person", "create"), ) - return { create } as const + const findById = (id: PersonId) => + pipe( + repo.findById(id), + Effect.withSpan("People.findById", { attributes: { id } }), + policyRequire("Person", "read"), + ) + + const with_ = ( + id: PersonId, + f: (person: Person) => Effect.Effect, + ): Effect.Effect => + pipe( + repo.findById(id), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new PersonNotFound({ id })), + onSome: Effect.succeed, + }), + ), + Effect.flatMap(f), + sql.withTransaction, + Effect.catchTag("SqlError", (e) => Effect.die(e)), + Effect.withSpan("People.with", { attributes: { id } }), + ) + + return { create, findById, with: with_ } as const }) export class People extends Effect.Tag("People")< @@ -27,5 +55,8 @@ export class People extends Effect.Tag("People")< Effect.Effect.Success >() { static layer = Layer.effect(People, make) - static Live = this.layer.pipe(Layer.provide(PeopleRepo.Live)) + static Live = this.layer.pipe( + Layer.provide(PeopleRepo.Live), + Layer.provide(SqlLive), + ) } diff --git a/examples/http-server/src/Api/People.ts b/examples/http-server/src/People/Api.ts similarity index 61% rename from examples/http-server/src/Api/People.ts rename to examples/http-server/src/People/Api.ts index 15f1812..dbc75b6 100644 --- a/examples/http-server/src/Api/People.ts +++ b/examples/http-server/src/People/Api.ts @@ -1,9 +1,9 @@ -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" -import { Schema } from "@effect/schema" import { GroupIdFromString, GroupNotFound } from "../Domain/Group.js" -import { Person } from "../Domain/Person.js" +import { Schema } from "@effect/schema" +import { Person, PersonIdFromString, PersonNotFound } from "../Domain/Person.js" import { Unauthorized } from "../Domain/Policy.js" -import { security } from "./Security.js" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" +import { security } from "../Api/Security.js" export class PeopleApi extends HttpApiGroup.make("people").pipe( HttpApiGroup.prefix("/people"), @@ -12,13 +12,20 @@ export class PeopleApi extends HttpApiGroup.make("people").pipe( HttpApiEndpoint.setPath(Schema.Struct({ groupId: GroupIdFromString })), HttpApiEndpoint.setSuccess(Person.json), HttpApiEndpoint.setPayload(Person.jsonCreate), - HttpApiEndpoint.addError(GroupNotFound) - ) + HttpApiEndpoint.addError(GroupNotFound), + ), + ), + HttpApiGroup.add( + HttpApiEndpoint.get("findById", "/:id").pipe( + HttpApiEndpoint.setPath(Schema.Struct({ id: PersonIdFromString })), + HttpApiEndpoint.setSuccess(Person.json), + HttpApiEndpoint.addError(PersonNotFound), + ), ), HttpApiGroup.addError(Unauthorized), OpenApi.annotate({ title: "People", description: "Manage people", - security - }) + security, + }), ) {} diff --git a/examples/http-server/src/People/Http.ts b/examples/http-server/src/People/Http.ts index 15ad92e..67672fd 100644 --- a/examples/http-server/src/People/Http.ts +++ b/examples/http-server/src/People/Http.ts @@ -6,26 +6,38 @@ import { policyUse } from "../Domain/Policy.js" import { Groups } from "../Groups.js" import { People } from "../People.js" import { PeoplePolicy } from "./Policy.js" +import { PersonNotFound } from "../Domain/Person.js" export const HttpPeopleLive = HttpApiBuilder.group(Api, "people", (handlers) => - Effect.gen(function*() { + Effect.gen(function* () { const groups = yield* Groups const people = yield* People const policy = yield* PeoplePolicy const accounts = yield* Accounts return handlers.pipe( - HttpApiBuilder.handle("create", ({ path, payload }) => + HttpApiBuilder.handle("create", ({ payload, path }) => groups.with(path.groupId, (group) => pipe( people.create(group.id, payload), - policyUse(policy.canCreate(group, payload)) - ))), - accounts.httpSecurity + policyUse(policy.canCreate(group.id, payload)), + ), + ), + ), + HttpApiBuilder.handle("findById", ({ path }) => + pipe( + people.findById(path.id), + Effect.flatten, + Effect.mapError(() => new PersonNotFound({ id: path.id })), + policyUse(policy.canRead(path.id)), + ), + ), + accounts.httpSecurity, ) - })).pipe( - Layer.provide(Accounts.Live), - Layer.provide(Groups.Live), - Layer.provide(People.Live), - Layer.provide(PeoplePolicy.Live) - ) + }), +).pipe( + Layer.provide(Accounts.Live), + Layer.provide(Groups.Live), + Layer.provide(People.Live), + Layer.provide(PeoplePolicy.Live), +) diff --git a/examples/http-server/src/People/Policy.ts b/examples/http-server/src/People/Policy.ts index 6d9a244..241a086 100644 --- a/examples/http-server/src/People/Policy.ts +++ b/examples/http-server/src/People/Policy.ts @@ -1,21 +1,52 @@ import { Effect, Layer, pipe } from "effect" -import type { Group } from "../Domain/Group.js" -import type { Person } from "../Domain/Person.js" -import type { policy, policyCompose } from "../Domain/Policy.js" +import { policy, policyCompose, Unauthorized } from "../Domain/Policy.js" +import { Person, PersonId } from "../Domain/Person.js" +import { GroupId } from "../Domain/Group.js" import { GroupsPolicy } from "../Groups/Policy.js" +import { Groups } from "../Groups.js" +import { People } from "../People.js" -const make = Effect.gen(function*() { +const make = Effect.gen(function* () { const groupsPolicy = yield* GroupsPolicy + const groups = yield* Groups + const people = yield* People - const canCreate = (group: Group, _person: typeof Person.jsonCreate.Type) => - pipe( - groupsPolicy.canUpdate(group), - policyCompose( - policy("Person", "create", (_actor) => Effect.succeed(true)) - ) + const canCreate = ( + groupId: GroupId, + _person: typeof Person.jsonCreate.Type, + ) => + Unauthorized.refail( + "Person", + "create", + )( + groups.with(groupId, (group) => + pipe( + groupsPolicy.canUpdate(group), + policyCompose( + policy("Person", "create", (_actor) => Effect.succeed(true)), + ), + ), + ), ) - return { canCreate } as const + const canRead = (id: PersonId) => + Unauthorized.refail( + "Person", + "read", + )( + people.with(id, (person) => + groups.with(person.groupId, (group) => + pipe( + groupsPolicy.canUpdate(group), + policyCompose( + policy("Person", "read", (_actor) => Effect.succeed(true)), + ), + ), + ), + ), + ) + + return { canCreate, canRead } as const }) export class PeoplePolicy extends Effect.Tag("People/Policy")< @@ -23,6 +54,8 @@ export class PeoplePolicy extends Effect.Tag("People/Policy")< Effect.Effect.Success >() { static Live = Layer.effect(PeoplePolicy, make).pipe( - Layer.provide(GroupsPolicy.Live) + Layer.provide(GroupsPolicy.Live), + Layer.provide(Groups.Live), + Layer.provide(People.Live), ) } diff --git a/examples/http-server/src/People/Repo.ts b/examples/http-server/src/People/Repo.ts index 6b0a224..55c0360 100644 --- a/examples/http-server/src/People/Repo.ts +++ b/examples/http-server/src/People/Repo.ts @@ -1,13 +1,27 @@ import { Model } from "@effect/sql" -import type { Effect } from "effect" -import { Context, Layer } from "effect" -import { Person } from "../Domain/Person.js" +import { Cache, Context, Effect, Layer } from "effect" +import { Person, PersonId } from "../Domain/Person.js" import { SqlLive } from "../Sql.js" -const make = Model.makeRepository(Person, { - tableName: "people", - spanPrefix: "PeopleRepo", - idColumn: "id" +const make = Effect.gen(function* () { + const repo = yield* Model.makeRepository(Person, { + tableName: "people", + spanPrefix: "PeopleRepo", + idColumn: "id", + }) + + const findById = yield* Cache.make({ + lookup: repo.findById, + capacity: 1024, + timeToLive: 30_000, + }) + + return { + ...repo, + findById(id: PersonId) { + return findById.get(id) + }, + } }) export class PeopleRepo extends Context.Tag("People/Repo")< diff --git a/examples/http-server/src/Sql.ts b/examples/http-server/src/Sql.ts index edea9ed..cc084e5 100644 --- a/examples/http-server/src/Sql.ts +++ b/examples/http-server/src/Sql.ts @@ -1,22 +1,22 @@ import { NodeContext } from "@effect/platform-node" -import { SqlClient } from "@effect/sql" import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node" import { Config, identity, Layer } from "effect" import { fileURLToPath } from "url" import { makeTestLayer } from "./lib/Layer.js" +import { SqlClient } from "@effect/sql" const ClientLive = SqliteClient.layer({ - filename: Config.succeed("data/db.sqlite") + filename: Config.succeed("data/db.sqlite"), }) const MigratorLive = SqliteMigrator.layer({ loader: SqliteMigrator.fromFileSystem( - fileURLToPath(new URL("./migrations", import.meta.url)) - ) + fileURLToPath(new URL("./migrations", import.meta.url)), + ), }).pipe(Layer.provide(NodeContext.layer)) export const SqlLive = MigratorLive.pipe(Layer.provideMerge(ClientLive)) export const SqlTest = makeTestLayer(SqlClient.SqlClient)({ - withTransaction: identity + withTransaction: identity, }) diff --git a/examples/http-server/src/Tracing.ts b/examples/http-server/src/Tracing.ts index 7813ad3..49886f6 100644 --- a/examples/http-server/src/Tracing.ts +++ b/examples/http-server/src/Tracing.ts @@ -4,44 +4,44 @@ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import { Config, Effect, Layer, Redacted } from "effect" export const TracingLive = Layer.unwrapEffect( - Effect.gen(function*() { + Effect.gen(function* () { const apiKey = yield* Config.option(Config.redacted("HONEYCOMB_API_KEY")) const dataset = yield* Config.withDefault( Config.string("HONEYCOMB_DATASET"), - "stremio-effect" + "stremio-effect", ) if (apiKey._tag === "None") { const endpoint = yield* Config.option( - Config.string("OTEL_EXPORTER_OTLP_ENDPOINT") + Config.string("OTEL_EXPORTER_OTLP_ENDPOINT"), ) if (endpoint._tag === "None") { return Layer.empty } return NodeSdk.layer(() => ({ resource: { - serviceName: dataset + serviceName: dataset, }, spanProcessor: new BatchSpanProcessor( - new OTLPTraceExporter({ url: `${endpoint.value}/v1/traces` }) - ) + new OTLPTraceExporter({ url: `${endpoint.value}/v1/traces` }), + ), })) } const headers = { "X-Honeycomb-Team": Redacted.value(apiKey.value), - "X-Honeycomb-Dataset": dataset + "X-Honeycomb-Dataset": dataset, } return NodeSdk.layer(() => ({ resource: { - serviceName: dataset + serviceName: dataset, }, spanProcessor: new BatchSpanProcessor( new OTLPTraceExporter({ url: "https://api.honeycomb.io/v1/traces", - headers - }) - ) + headers, + }), + ), })) - }) + }), ) diff --git a/examples/http-server/src/Uuid.ts b/examples/http-server/src/Uuid.ts index 96689ad..2c0caba 100644 --- a/examples/http-server/src/Uuid.ts +++ b/examples/http-server/src/Uuid.ts @@ -1,8 +1,7 @@ import { Context, Effect, Layer } from "effect" import * as Api from "uuid" -// eslint-disable-next-line require-yield -const make = Effect.gen(function*() { +const make = Effect.gen(function* () { const generate = Effect.sync(() => Api.v7()) return { generate } as const }) @@ -11,8 +10,8 @@ export class Uuid extends Context.Tag("Uuid")< Uuid, Effect.Effect.Success >() { - static Live = Layer.succeed(Uuid, make) + static Live = Layer.effect(Uuid, make) static Test = Layer.succeed(Uuid, { - generate: Effect.succeed("test-uuid") + generate: Effect.succeed("test-uuid"), }) } diff --git a/examples/http-server/src/client.ts b/examples/http-server/src/client.ts index 5bfe27f..b10f2e1 100644 --- a/examples/http-server/src/client.ts +++ b/examples/http-server/src/client.ts @@ -4,14 +4,14 @@ import { Effect } from "effect" import { Api } from "./Api.js" import { Email } from "./Domain/Email.js" -Effect.gen(function*() { +Effect.gen(function* () { const client = yield* HttpApiClient.make(Api, { - baseUrl: "http://localhost:3000" + baseUrl: "http://localhost:3000", }) const user = yield* client.accounts.createUser({ payload: { - email: Email.make("joe@example.com") - } + email: Email.make("john.bloggs@example.com"), + }, }) console.log(user) }).pipe(Effect.provide(NodeHttpClient.layerUndici), NodeRuntime.runMain) diff --git a/examples/http-server/src/lib/Layer.ts b/examples/http-server/src/lib/Layer.ts index cb9a337..8100d9b 100644 --- a/examples/http-server/src/lib/Layer.ts +++ b/examples/http-server/src/lib/Layer.ts @@ -1,5 +1,4 @@ -import type { Context } from "effect" -import { Effect, Layer } from "effect" +import { Context, Effect, Layer } from "effect" const makeUnimplemented = (id: string, prop: PropertyKey) => { const dead = Effect.die(`${id}: Unimplemented method "${prop.toString()}"`) @@ -13,7 +12,7 @@ const makeUnimplemented = (id: string, prop: PropertyKey) => { const makeUnimplementedProxy = ( service: string, - impl: Partial + impl: Partial, ): A => new Proxy({ ...impl } as A, { get(target, prop, _receiver) { @@ -22,8 +21,10 @@ const makeUnimplementedProxy = ( } return ((target as any)[prop] = makeUnimplemented(service, prop)) }, - has: () => true + has: () => true, }) -export const makeTestLayer = (tag: Context.Tag) => (service: Partial): Layer.Layer => - Layer.succeed(tag, makeUnimplementedProxy(tag.key, service)) +export const makeTestLayer = + (tag: Context.Tag) => + (service: Partial): Layer.Layer => + Layer.succeed(tag, makeUnimplementedProxy(tag.key, service)) diff --git a/examples/http-server/src/migrations/00001_create users.ts b/examples/http-server/src/migrations/00001_create users.ts index c8d63f9..3d5621c 100644 --- a/examples/http-server/src/migrations/00001_create users.ts +++ b/examples/http-server/src/migrations/00001_create users.ts @@ -1,29 +1,26 @@ import { SqlClient } from "@effect/sql" import { Effect } from "effect" -export default Effect.gen(function*() { +export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql.onDialectOrElse({ - pg: () => - sql` + pg: () => sql` CREATE TABLE accounts ( id SERIAL PRIMARY KEY, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ) `, - orElse: () => - sql` + orElse: () => sql` CREATE TABLE accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ) - ` + `, }) yield* sql.onDialectOrElse({ - pg: () => - sql` + pg: () => sql` CREATE TABLE users ( id SERIAL PRIMARY KEY, accountId INTEGER NOT NULL, @@ -34,8 +31,7 @@ export default Effect.gen(function*() { FOREIGN KEY (accountId) REFERENCES accounts(id) ) `, - orElse: () => - sql` + orElse: () => sql` CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, accountId INTEGER NOT NULL, @@ -45,6 +41,6 @@ export default Effect.gen(function*() { updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, FOREIGN KEY (accountId) REFERENCES accounts(id) ) - ` + `, }) }) diff --git a/examples/http-server/src/migrations/00002_create groups.ts b/examples/http-server/src/migrations/00002_create groups.ts index c7f0a3b..e4c5128 100644 --- a/examples/http-server/src/migrations/00002_create groups.ts +++ b/examples/http-server/src/migrations/00002_create groups.ts @@ -1,11 +1,10 @@ import { SqlClient } from "@effect/sql" import { Effect } from "effect" -export default Effect.gen(function*() { +export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql.onDialectOrElse({ - pg: () => - sql` + pg: () => sql` CREATE TABLE groups ( id SERIAL PRIMARY KEY, ownerId INTEGER NOT NULL, @@ -15,8 +14,7 @@ export default Effect.gen(function*() { FOREIGN KEY (ownerId) REFERENCES accounts(id) ) `, - orElse: () => - sql` + orElse: () => sql` CREATE TABLE groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, ownerId INTEGER NOT NULL, @@ -25,6 +23,6 @@ export default Effect.gen(function*() { updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, FOREIGN KEY (ownerId) REFERENCES accounts(id) ) - ` + `, }) }) diff --git a/examples/http-server/src/migrations/00003_create_people.ts b/examples/http-server/src/migrations/00003_create_people.ts index b9f8e48..cd228d3 100644 --- a/examples/http-server/src/migrations/00003_create_people.ts +++ b/examples/http-server/src/migrations/00003_create_people.ts @@ -1,11 +1,10 @@ import { SqlClient } from "@effect/sql" import { Effect } from "effect" -export default Effect.gen(function*() { +export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql.onDialectOrElse({ - pg: () => - sql` + pg: () => sql` CREATE TABLE people ( id SERIAL PRIMARY KEY, groupId INTEGER NOT NULL, @@ -17,8 +16,7 @@ export default Effect.gen(function*() { FOREIGN KEY (ownerId) REFERENCES groups(id) ) `, - orElse: () => - sql` + orElse: () => sql` CREATE TABLE people ( id INTEGER PRIMARY KEY AUTOINCREMENT, groupId INTEGER NOT NULL, @@ -29,6 +27,6 @@ export default Effect.gen(function*() { updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, FOREIGN KEY (groupId) REFERENCES groups(id) ) - ` + `, }) }) diff --git a/examples/http-server/tsconfig.json b/examples/http-server/tsconfig.json index 2edec47..6cefa88 100644 --- a/examples/http-server/tsconfig.json +++ b/examples/http-server/tsconfig.json @@ -3,7 +3,7 @@ "composite": true, "incremental": true }, - "include": [], + "files": [], "references": [ { "path": "./tsconfig.src.json" }, { "path": "./tsconfig.test.json" } diff --git a/examples/http-server/vitest.config.ts b/examples/http-server/vitest.config.ts index 54f6936..ffa293b 100644 --- a/examples/http-server/vitest.config.ts +++ b/examples/http-server/vitest.config.ts @@ -1,10 +1,10 @@ -import * as Path from "node:path" import { defineConfig } from "vitest/config" +import * as Path from "node:path" export default defineConfig({ test: { alias: { - app: Path.join(__dirname, "src") - } - } + app: Path.join(__dirname, "src"), + }, + }, })