diff --git a/bun.lock b/bun.lock index 2674a6e..81d9c0b 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "packages/anonymize-ip": { "name": "@lowerdeck/anonymize-ip", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -21,9 +21,35 @@ "vitest": "^3.1.2", }, }, + "packages/api-key": { + "name": "@lowerdeck/api-key", + "version": "1.0.0", + "dependencies": { + "@lowerdeck/id": "^1.0.2", + }, + "devDependencies": { + "@lowerdeck/tsconfig": "^1.0.0", + "microbundle": "^0.15.1", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + }, + }, + "packages/api-mux": { + "name": "@lowerdeck/api-mux", + "version": "1.0.0", + "dependencies": { + "@lowerdeck/error": "^1.0.5", + }, + "devDependencies": { + "@lowerdeck/tsconfig": "^1.0.0", + "microbundle": "^0.15.1", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + }, + }, "packages/base62": { "name": "@lowerdeck/base62", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "base-x": "^5.0.0", }, @@ -36,7 +62,7 @@ }, "packages/canonicalize": { "name": "@lowerdeck/canonicalize", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -46,7 +72,7 @@ }, "packages/case": { "name": "@lowerdeck/case", - "version": "1.0.6", + "version": "1.0.8", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -56,7 +82,7 @@ }, "packages/cron": { "name": "@lowerdeck/cron", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@lowerdeck/queue": "^1.0.0", "@lowerdeck/redis": "^1.0.0", @@ -71,7 +97,7 @@ }, "packages/delay": { "name": "@lowerdeck/delay", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -81,7 +107,7 @@ }, "packages/emitter": { "name": "@lowerdeck/emitter", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -91,7 +117,7 @@ }, "packages/encryption": { "name": "@lowerdeck/encryption", - "version": "1.0.3", + "version": "1.0.5", "dependencies": { "@lowerdeck/base62": "^1.0.0", "@lowerdeck/id": "^1.0.0", @@ -106,7 +132,7 @@ }, "packages/env": { "name": "@lowerdeck/env", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "@lowerdeck/validation": "^1.0.1", }, @@ -119,10 +145,10 @@ }, "packages/error": { "name": "@lowerdeck/error", - "version": "1.0.5", + "version": "1.0.7", "dependencies": { - "@lowerdeck/case": "^1.0.6", - "@lowerdeck/validation": "^1.0.1", + "@lowerdeck/case": "^1.0.8", + "@lowerdeck/validation": "^1.0.3", }, "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", @@ -147,7 +173,7 @@ }, "packages/flatten": { "name": "@lowerdeck/flatten", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -157,7 +183,7 @@ }, "packages/forwarded-for": { "name": "@lowerdeck/forwarded-for", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -167,9 +193,9 @@ }, "packages/hash": { "name": "@lowerdeck/hash", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { - "@lowerdeck/base62": "^1.0.1", + "@lowerdeck/base62": "^1.0.3", }, "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", @@ -180,7 +206,7 @@ }, "packages/hono": { "name": "@lowerdeck/hono", - "version": "1.0.2", + "version": "1.0.4", "dependencies": { "@lowerdeck/error": "^1.0.5", "@lowerdeck/forwarded-for": "^1.0.1", @@ -196,10 +222,10 @@ }, "packages/id": { "name": "@lowerdeck/id", - "version": "1.0.2", + "version": "1.0.4", "dependencies": { - "@lowerdeck/error": "^1.0.5", - "@lowerdeck/hash": "^1.0.1", + "@lowerdeck/error": "^1.0.7", + "@lowerdeck/hash": "^1.0.3", "nanoid": "^5.0.7", "short-uuid": "^5.2.0", "snowflake-uuid": "^1.0.0", @@ -213,7 +239,7 @@ }, "packages/ip-info": { "name": "@lowerdeck/ip-info", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -223,7 +249,7 @@ }, "packages/joinPaths": { "name": "@lowerdeck/join-paths", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -233,7 +259,7 @@ }, "packages/jwt": { "name": "@lowerdeck/jwt", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "jose": "^5.6.3", }, @@ -246,7 +272,7 @@ }, "packages/lock": { "name": "@lowerdeck/lock", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@lowerdeck/delay": "^1.0.0", "@lowerdeck/redis": "^1.0.0", @@ -264,7 +290,7 @@ }, "packages/memo": { "name": "@lowerdeck/memo", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -274,7 +300,7 @@ }, "packages/merge": { "name": "@lowerdeck/merge", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -284,7 +310,7 @@ }, "packages/murmur3": { "name": "@lowerdeck/murmur3", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -294,7 +320,7 @@ }, "packages/normalize-email": { "name": "@lowerdeck/normalize-email", - "version": "1.0.0", + "version": "1.0.2", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -304,7 +330,7 @@ }, "packages/once": { "name": "@lowerdeck/once", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -312,9 +338,37 @@ "vitest": "^3.1.2", }, }, + "packages/pagination": { + "name": "@lowerdeck/pagination", + "version": "1.0.0", + "dependencies": { + "@lowerdeck/base62": "^1.0.2", + "@lowerdeck/error": "^1.0.5", + "@lowerdeck/validation": "^1.0.1", + }, + "devDependencies": { + "@lowerdeck/tsconfig": "^1.0.0", + "microbundle": "^0.15.1", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + }, + }, + "packages/presenter": { + "name": "@lowerdeck/presenter", + "version": "1.0.0", + "dependencies": { + "@lowerdeck/validation": "^1.0.1", + }, + "devDependencies": { + "@lowerdeck/tsconfig": "^1.0.0", + "microbundle": "^0.15.1", + "typescript": "^5.9.3", + "vitest": "^3.2.4", + }, + }, "packages/programmable-promise": { "name": "@lowerdeck/programmable-promise", - "version": "1.0.3", + "version": "1.0.5", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -324,7 +378,7 @@ }, "packages/proxy": { "name": "@lowerdeck/proxy", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -334,7 +388,7 @@ }, "packages/queue": { "name": "@lowerdeck/queue", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@lowerdeck/delay": "^1.0.0", "@lowerdeck/memo": "^1.0.0", @@ -351,7 +405,7 @@ }, "packages/random-from-array": { "name": "@lowerdeck/random-from-array", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -361,7 +415,7 @@ }, "packages/random-number": { "name": "@lowerdeck/random-number", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -371,7 +425,7 @@ }, "packages/redis": { "name": "@lowerdeck/redis", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@lowerdeck/id": "^1.0.0", "@lowerdeck/memo": "^1.0.0", @@ -441,7 +495,7 @@ }, "packages/serialize": { "name": "@lowerdeck/serialize", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "superjson": "^2.2.5", }, @@ -467,7 +521,7 @@ }, "packages/shadow-id": { "name": "@lowerdeck/shadow-id", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "@lowerdeck/base62": "^1.0.1", }, @@ -480,7 +534,7 @@ }, "packages/sign": { "name": "@lowerdeck/sign", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "@lowerdeck/base62": "^1.0.1", "@lowerdeck/id": "^1.0.1", @@ -494,7 +548,7 @@ }, "packages/slugify": { "name": "@lowerdeck/slugify", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "@lowerdeck/id": "^1.0.1", "slugify": "^1.6.6", @@ -508,7 +562,7 @@ }, "packages/timezone": { "name": "@lowerdeck/timezone", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -518,7 +572,7 @@ }, "packages/tokens": { "name": "@lowerdeck/tokens", - "version": "1.0.2", + "version": "1.0.4", "dependencies": { "@lowerdeck/base62": "^1.0.1", "@lowerdeck/memo": "^1.0.1", @@ -539,7 +593,7 @@ }, "packages/unique": { "name": "@lowerdeck/unique", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -549,7 +603,7 @@ }, "packages/validation": { "name": "@lowerdeck/validation", - "version": "1.0.1", + "version": "1.0.3", "devDependencies": { "@lowerdeck/tsconfig": "^1.0.0", "microbundle": "^0.15.1", @@ -559,7 +613,7 @@ }, "packages/websocket-client": { "name": "@lowerdeck/websocket-client", - "version": "1.0.1", + "version": "1.0.3", "dependencies": { "@lowerdeck/emitter": "^1.0.1", }, @@ -848,6 +902,10 @@ "@lowerdeck/anonymize-ip": ["@lowerdeck/anonymize-ip@workspace:packages/anonymize-ip"], + "@lowerdeck/api-key": ["@lowerdeck/api-key@workspace:packages/api-key"], + + "@lowerdeck/api-mux": ["@lowerdeck/api-mux@workspace:packages/api-mux"], + "@lowerdeck/base62": ["@lowerdeck/base62@workspace:packages/base62"], "@lowerdeck/canonicalize": ["@lowerdeck/canonicalize@workspace:packages/canonicalize"], @@ -896,6 +954,10 @@ "@lowerdeck/once": ["@lowerdeck/once@workspace:packages/once"], + "@lowerdeck/pagination": ["@lowerdeck/pagination@workspace:packages/pagination"], + + "@lowerdeck/presenter": ["@lowerdeck/presenter@workspace:packages/presenter"], + "@lowerdeck/programmable-promise": ["@lowerdeck/programmable-promise@workspace:packages/programmable-promise"], "@lowerdeck/proxy": ["@lowerdeck/proxy@workspace:packages/proxy"], @@ -1974,8 +2036,6 @@ "@lowerdeck/redis/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], - "@lowerdeck/rpc-client/@lowerdeck/canonicalize": ["@lowerdeck/canonicalize@1.0.1", "", {}, "sha512-RveZ6hbq6TqPa3XJLcKkNf6U3ifoMQFEP7VnBlwo9OT7XpF+o5buXUr5l4rhQ9WHzNDkTPB94/6R8YbRj9NfZw=="], - "@lowerdeck/sentry/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], "@lowerdeck/serialize/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], diff --git a/packages/presenter/README.md b/packages/presenter/README.md new file mode 100644 index 0000000..b1a639f --- /dev/null +++ b/packages/presenter/README.md @@ -0,0 +1,54 @@ +# `@lowerdeck/presenter` + +Type-safe presentation layer for transforming domain objects into API responses. Supports multiple API versions, validation schemas, and context-aware transformations. + +## Installation + +```bash +npm install @lowerdeck/presenter +yarn add @lowerdeck/presenter +bun add @lowerdeck/presenter +pnpm add @lowerdeck/presenter +``` + +## Usage + +```typescript +import { PresentableType, Presenter, declarePresenter } from '@lowerdeck/presenter'; +import { v } from '@lowerdeck/validation'; + +// Define a presentable type +const UserType = PresentableType.create<{ id: string; name: string }>()('user'); + +// Create a presenter +const userPresenter = Presenter.create(UserType) + .presenter(async (user, context) => ({ + object: 'user' as const, + id: user.id, + name: user.name, + email: context.accessType === 'user_auth_token' ? 'hidden' : user.email + })) + .schema(v.object({ + object: v.literal('user'), + id: v.string(), + name: v.string() + })) + .build(); + +// Present data +const result = userPresenter.present( + { id: '123', name: 'John' }, + { apiVersion: 'mt_2025_01_01_pulsar', accessType: 'user_auth_token' } +); + +const data = await result.run({}); +console.log(data); // { object: 'user', id: '123', name: 'John', ... } +``` + +## License + +This project is licensed under the Apache License 2.0. + +
+ Built with ❤️ by Metorial +
diff --git a/packages/presenter/package.json b/packages/presenter/package.json new file mode 100644 index 0000000..b7596d4 --- /dev/null +++ b/packages/presenter/package.json @@ -0,0 +1,35 @@ +{ + "name": "@lowerdeck/presenter", + "version": "1.0.0", + "publishConfig": { + "access": "public" + }, + "author": "Tobias Herber", + "license": "Apache 2", + "type": "module", + "source": "src/index.ts", + "exports": { + "types": "./dist/index.d.ts", + "require": "./dist/index.cjs", + "import": "./dist/index.module.js", + "default": "./dist/index.module.js" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.module.js", + "types": "dist/index.d.ts", + "unpkg": "./dist/index.umd.js", + "scripts": { + "test": "vitest run --passWithNoTests", + "lint": "prettier src/**/*.ts --check", + "build": "microbundle" + }, + "devDependencies": { + "microbundle": "^0.15.1", + "@lowerdeck/tsconfig": "^1.0.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "dependencies": { + "@lowerdeck/validation": "^1.0.1" + } +} \ No newline at end of file diff --git a/packages/presenter/src/index.ts b/packages/presenter/src/index.ts new file mode 100644 index 0000000..6ff98ed --- /dev/null +++ b/packages/presenter/src/index.ts @@ -0,0 +1 @@ +export * from './presenter'; diff --git a/packages/presenter/src/presenter.ts b/packages/presenter/src/presenter.ts new file mode 100644 index 0000000..4a453bd --- /dev/null +++ b/packages/presenter/src/presenter.ts @@ -0,0 +1,184 @@ +import { introspectType, ValidationType } from '@lowerdeck/validation'; + +export interface PresenterContext { + // instance: Instance; + apiVersion: 'mt_2025_01_01_pulsar' | 'mt_2025_01_01_dashboard'; + accessType: + | 'instance_secret' + | 'instance_publishable' + | 'organization_management' + | 'user_auth_token' + | 'event_system'; +} + +export interface PresenterResult { + run: (i?: { expand?: string[] }) => Promise; +} + +export interface Expansion { + provider: (object: Object, context: Context) => Promise; + presenter: Presenter, any>; +} + +export class PresentableType { + private constructor(public readonly name: Name) {} + + static create() { + return (name: Name) => new PresentableType(name); + } + + present(input: Type) { + return { presenter: this.name, input }; + } +} + +export type GetTypeOfPresentable = + T extends PresentableType ? Type : never; + +export class Presenter, Output extends {}> { + private constructor( + private readonly presentableType: Type, + private readonly presenter: ( + input: GetTypeOfPresentable, + context: PresenterContext + ) => Promise, + public readonly schema: ValidationType + ) {} + + static $$create$$_internal, Output extends {}>( + presentableType: Type, + presenter: ( + input: GetTypeOfPresentable, + context: PresenterContext + ) => Promise, + type: ValidationType + ) { + return new Presenter(presentableType, presenter, type); + } + + static create>(name: Type) { + return new PresenterBuilder(name); + } + + present( + input: GetTypeOfPresentable, + context: PresenterContext + ): PresenterResult { + // return this.presenter(input, context); + + return { + run: async opts => { + let { expand } = opts ?? {}; + + let result = await this.presenter(input, context); + + // if (expand?.length) { + // for (let expansionKey of expand) { + // let expander = this.expansions[expansionKey]; + // let canExpand = await context.expansions?.[expansionKey]?.(); + // if (!expander || canExpand === false || canExpand === null) { + // throw new ServiceError( + // badRequestError({ + // message: `Expansion "${expansionKey}" is not supported for object "${this.presentableType.name}"` + // }) + // ); + // } + + // let expansionUnpresented = await expander.provider(result, context); + // let expansionValue = await expander.presenter + // .present(expansionUnpresented, { ...context, expansions: undefined }) + // .run({}); + + // result = { + // ...result, + // [expansionKey]: expansionValue + // }; + // } + // } + + // @ts-ignore + result.__typename = this.name; + + return result; + } + }; + } + + introspect() { + return { + name: this.presentableType.name, + object: introspectType(this.schema) + // expansions: Object.entries(this.expansions).map(([key, expansion]) => ({ + // name: key, + // object: introspectType(expansion.presenter.schema) + // })) + }; + } +} + +export class PresenterBuilder, Output extends {}> { + #presenter?: ( + input: GetTypeOfPresentable, + context: PresenterContext + ) => Promise; + #type?: ValidationType; + + constructor(private readonly presentableType: Type) {} + + presenter< + Output extends { + object: string; + } + >( + presenter: ( + input: GetTypeOfPresentable, + context: PresenterContext + ) => Promise + ) { + // @ts-ignore + this.#presenter = presenter; + return this as any as PresenterBuilder; + } + + schema(type: ValidationType) { + // @ts-ignore + this.#type = type; + return this; + } + + // expansion>( + // key: Key, + // expansion: Exp + // ) { + // // @ts-ignore + // this.#expansions = { + // ...this.#expansions, + // [key]: expansion + // }; + // return this as any as PresenterBuilder>; + // } + + build() { + if (!this.#presenter || !this.#type) { + throw new Error('Presenter must have a presenter and a type'); + } + + return Presenter.$$create$$_internal(this.presentableType, this.#presenter!, this.#type!); + } +} + +export let declarePresenter = >( + type: Type, + presenters: { + mt_2025_01_01_pulsar: Presenter; + mt_2025_01_01_dashboard: Presenter; + } +) => ({ + type, + present: + (input: GetTypeOfPresentable) => + (context: PresenterContext): PresenterResult => + presenters[context.apiVersion].present(input, context), + introspect: ({ apiVersion }: { apiVersion: string }) => + (presenters as any)[apiVersion].introspect() +}); diff --git a/packages/presenter/tsconfig.json b/packages/presenter/tsconfig.json new file mode 100644 index 0000000..c62d3e3 --- /dev/null +++ b/packages/presenter/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@lowerdeck/tsconfig/base.json", + "exclude": [ + "dist" + ], + "include": [ + "src" + ], + "compilerOptions": { + "outDir": "dist" + } +} \ No newline at end of file diff --git a/packages/presenter/tsup.config.js b/packages/presenter/tsup.config.js new file mode 100644 index 0000000..4e2a3d6 --- /dev/null +++ b/packages/presenter/tsup.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + splitting: false, + sourcemap: true, + clean: true, + bundle: true, + dts: true +});