From eb3a88b99685a0b188581777028472156efe7072 Mon Sep 17 00:00:00 2001 From: "NMNM.CC" Date: Tue, 14 Jul 2026 18:44:52 +0800 Subject: [PATCH 1/4] feat(plugin-effect): add Effect v4 beta schemas --- .changeset/warm-effects-generate.md | 5 + .github/labeler.yml | 3 + README.md | 9 +- examples/effect/kubb.config.js | 14 + examples/effect/package.json | 32 ++ examples/effect/src/effect.test.ts | 57 +++ examples/effect/src/gen/effect/AddPet.ts | 53 ++ .../effect/src/gen/effect/AddPetRequest.ts | 26 + examples/effect/src/gen/effect/ApiResponse.ts | 14 + examples/effect/src/gen/effect/Category.ts | 16 + examples/effect/src/gen/effect/CreatePets.ts | 39 ++ examples/effect/src/gen/effect/DeleteOrder.ts | 26 + examples/effect/src/gen/effect/DeletePet.ts | 26 + .../effect/src/gen/effect/FindPetsByStatus.ts | 37 ++ .../effect/src/gen/effect/FindPetsByTags.ts | 47 ++ .../effect/src/gen/effect/GetInventory.ts | 16 + .../effect/src/gen/effect/GetOrderById.ts | 39 ++ examples/effect/src/gen/effect/GetPetById.ts | 39 ++ examples/effect/src/gen/effect/GetThings.ts | 38 ++ examples/effect/src/gen/effect/Order.ts | 29 ++ examples/effect/src/gen/effect/Pet.ts | 41 ++ examples/effect/src/gen/effect/PetNotFound.ts | 13 + examples/effect/src/gen/effect/PhoneNumber.ts | 10 + .../src/gen/effect/PhoneWithMaxLength.ts | 13 + .../gen/effect/PhoneWithMaxLengthExplicit.ts | 13 + examples/effect/src/gen/effect/PlaceOrder.ts | 39 ++ .../effect/src/gen/effect/PlaceOrderPatch.ts | 39 ++ examples/effect/src/gen/effect/Tag.ts | 10 + examples/effect/src/gen/effect/UpdatePet.ts | 61 +++ .../src/gen/effect/UpdatePetWithForm.ts | 30 ++ examples/effect/src/gen/effect/UploadFile.ts | 27 + examples/effect/src/gen/effect/index.ts | 120 +++++ examples/effect/src/gen/index.ts | 120 +++++ examples/effect/src/index.ts | 1 + examples/effect/tsconfig.json | 13 + packages/plugin-effect/CHANGELOG.md | 1 + packages/plugin-effect/README.md | 56 +++ packages/plugin-effect/package.json | 69 +++ .../src/components/EffectSchema.tsx | 41 ++ .../src/generators/effectGenerator.tsx | 214 ++++++++ packages/plugin-effect/src/index.ts | 7 + packages/plugin-effect/src/plugin.ts | 66 +++ .../src/printers/printerEffect.test.ts | 134 +++++ .../src/printers/printerEffect.ts | 474 ++++++++++++++++++ .../src/resolvers/resolverEffect.test.ts | 16 + .../src/resolvers/resolverEffect.ts | 22 + packages/plugin-effect/src/types.ts | 128 +++++ packages/plugin-effect/tsconfig.json | 9 + packages/plugin-effect/tsdown.config.ts | 35 ++ packages/plugin-effect/vitest.config.ts | 10 + pnpm-lock.yaml | 365 +++++++------- pnpm-workspace.yaml | 3 + tsconfig.json | 3 +- 53 files changed, 2585 insertions(+), 183 deletions(-) create mode 100644 .changeset/warm-effects-generate.md create mode 100644 examples/effect/kubb.config.js create mode 100644 examples/effect/package.json create mode 100644 examples/effect/src/effect.test.ts create mode 100644 examples/effect/src/gen/effect/AddPet.ts create mode 100644 examples/effect/src/gen/effect/AddPetRequest.ts create mode 100644 examples/effect/src/gen/effect/ApiResponse.ts create mode 100644 examples/effect/src/gen/effect/Category.ts create mode 100644 examples/effect/src/gen/effect/CreatePets.ts create mode 100644 examples/effect/src/gen/effect/DeleteOrder.ts create mode 100644 examples/effect/src/gen/effect/DeletePet.ts create mode 100644 examples/effect/src/gen/effect/FindPetsByStatus.ts create mode 100644 examples/effect/src/gen/effect/FindPetsByTags.ts create mode 100644 examples/effect/src/gen/effect/GetInventory.ts create mode 100644 examples/effect/src/gen/effect/GetOrderById.ts create mode 100644 examples/effect/src/gen/effect/GetPetById.ts create mode 100644 examples/effect/src/gen/effect/GetThings.ts create mode 100644 examples/effect/src/gen/effect/Order.ts create mode 100644 examples/effect/src/gen/effect/Pet.ts create mode 100644 examples/effect/src/gen/effect/PetNotFound.ts create mode 100644 examples/effect/src/gen/effect/PhoneNumber.ts create mode 100644 examples/effect/src/gen/effect/PhoneWithMaxLength.ts create mode 100644 examples/effect/src/gen/effect/PhoneWithMaxLengthExplicit.ts create mode 100644 examples/effect/src/gen/effect/PlaceOrder.ts create mode 100644 examples/effect/src/gen/effect/PlaceOrderPatch.ts create mode 100644 examples/effect/src/gen/effect/Tag.ts create mode 100644 examples/effect/src/gen/effect/UpdatePet.ts create mode 100644 examples/effect/src/gen/effect/UpdatePetWithForm.ts create mode 100644 examples/effect/src/gen/effect/UploadFile.ts create mode 100644 examples/effect/src/gen/effect/index.ts create mode 100644 examples/effect/src/gen/index.ts create mode 100644 examples/effect/src/index.ts create mode 100644 examples/effect/tsconfig.json create mode 100644 packages/plugin-effect/CHANGELOG.md create mode 100644 packages/plugin-effect/README.md create mode 100644 packages/plugin-effect/package.json create mode 100644 packages/plugin-effect/src/components/EffectSchema.tsx create mode 100644 packages/plugin-effect/src/generators/effectGenerator.tsx create mode 100644 packages/plugin-effect/src/index.ts create mode 100644 packages/plugin-effect/src/plugin.ts create mode 100644 packages/plugin-effect/src/printers/printerEffect.test.ts create mode 100644 packages/plugin-effect/src/printers/printerEffect.ts create mode 100644 packages/plugin-effect/src/resolvers/resolverEffect.test.ts create mode 100644 packages/plugin-effect/src/resolvers/resolverEffect.ts create mode 100644 packages/plugin-effect/src/types.ts create mode 100644 packages/plugin-effect/tsconfig.json create mode 100644 packages/plugin-effect/tsdown.config.ts create mode 100644 packages/plugin-effect/vitest.config.ts diff --git a/.changeset/warm-effects-generate.md b/.changeset/warm-effects-generate.md new file mode 100644 index 000000000..e914d2553 --- /dev/null +++ b/.changeset/warm-effects-generate.md @@ -0,0 +1,5 @@ +--- +"@kubb/plugin-effect": minor +--- + +Add `@kubb/plugin-effect` for generating Effect v4 beta schemas and matching TypeScript types from OpenAPI. Date-time codecs decode wire strings and annotations into Effect `DateTime.Utc` values. diff --git a/.github/labeler.yml b/.github/labeler.yml index 465e513ef..ab9f05e1f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -49,6 +49,9 @@ '@kubb/plugin-zod': - any: ['packages/plugin-zod/**', '!packages/plugin-zod/package.json'] +'@kubb/plugin-effect': + - any: ['packages/plugin-effect/**', '!packages/plugin-effect/package.json'] + 'unplugin-kubb': - any: ['packages/unplugin-kubb/**', '!packages/unplugin-kubb/package.json'] diff --git a/README.md b/README.md index ccdd4b16c..cca3b2752 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ **Official and community plugins for [Kubb](https://kubb.dev).** -This monorepo is home to official and community plugins for [Kubb](https://kubb.dev), the meta framework for code generation. Point Kubb at your OpenAPI specification and it generates TypeScript types, API clients, Zod schemas, React/Vue/Svelte/Solid Query hooks, Faker mocks, MSW handlers, and more. +This monorepo is home to official and community plugins for [Kubb](https://kubb.dev), the meta framework for code generation. Point Kubb at your OpenAPI specification and it generates TypeScript types, API clients, Effect and Zod schemas, React/Vue/Svelte/Solid Query hooks, Faker mocks, MSW handlers, and more. Want to build your own plugin? See [CONTRIBUTING.md](./CONTRIBUTING.md). @@ -49,6 +49,12 @@ Maintained by the Kubb team. Kubb v5 OpenAPI configs use [`@kubb/adapter-oas`](h | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | [`@kubb/plugin-zod`](./packages/plugin-zod) | [![npm version](https://img.shields.io/npm/v/@kubb/plugin-zod.svg)](https://npmx.dev/package/@kubb/plugin-zod) | [Zod](https://github.com/colinhacks/zod) schema generation for runtime validation | +### Effect + +| Package | Version | Description | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [`@kubb/plugin-effect`](./packages/plugin-effect) | [![npm version](https://img.shields.io/npm/v/@kubb/plugin-effect.svg)](https://npmx.dev/package/@kubb/plugin-effect) | [Effect](https://github.com/Effect-TS/effect-smol) v4 schema and TypeScript generation | + ### Data fetching | Package | Version | Description | @@ -85,6 +91,7 @@ Plugins built and maintained by the community. Want to add yours? See [CONTRIBUT | [`client`](./examples/client) | Generate API clients with Axios | | [`fetch`](./examples/fetch) | Generate API clients with Fetch | | [`zod`](./examples/zod) | Generate Zod validation schemas | +| [`effect`](./examples/effect) | Generate Effect v4 schemas | | [`react-query`](./examples/react-query) | Generate React Query hooks | | [`vue-query`](./examples/vue-query) | Generate Vue Query composables | | [`faker`](./examples/faker) | Generate Faker.js mock data | diff --git a/examples/effect/kubb.config.js b/examples/effect/kubb.config.js new file mode 100644 index 000000000..f796358ce --- /dev/null +++ b/examples/effect/kubb.config.js @@ -0,0 +1,14 @@ +import { adapterOas } from '@kubb/adapter-oas' +import { pluginEffect } from '@kubb/plugin-effect' +import { defineConfig } from 'kubb/config' + +export default defineConfig({ + root: '.', + input: '../zod/petStore.yaml', + adapter: adapterOas({ unknownType: 'unknown', dateType: 'date' }), + output: { + path: './src/gen', + clean: true, + }, + plugins: [pluginEffect()], +}) diff --git a/examples/effect/package.json b/examples/effect/package.json new file mode 100644 index 000000000..2063f6001 --- /dev/null +++ b/examples/effect/package.json @@ -0,0 +1,32 @@ +{ + "name": "effect-pet-store", + "version": "0.0.0", + "private": true, + "description": "Effect v4 PetStore example", + "license": "MIT", + "author": "stijnvanhulle", + "repository": { + "type": "git", + "url": "https://github.com/kubb-labs/plugins.git", + "directory": "examples/effect" + }, + "type": "module", + "sideEffects": false, + "scripts": { + "generate": "KUBB_DISABLE_TELEMETRY=1 kubb --config kubb.config.js", + "test": "vitest --passWithNoTests", + "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@kubb/adapter-oas": "catalog:", + "@kubb/plugin-effect": "workspace:*", + "effect": "catalog:", + "kubb": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22", + "pnpm": ">=11.0.0" + }, + "packageManager": "pnpm@11.5.0" +} diff --git a/examples/effect/src/effect.test.ts b/examples/effect/src/effect.test.ts new file mode 100644 index 000000000..1b5769683 --- /dev/null +++ b/examples/effect/src/effect.test.ts @@ -0,0 +1,57 @@ +import * as DateTime from 'effect/DateTime' +import * as Schema from 'effect/Schema' +import * as SchemaGetter from 'effect/SchemaGetter' +import { describe, expect, test } from 'vitest' +import type { Order as OrderType, Pet as PetType } from './gen/effect/index.ts' +import { GetThingsQueryLimit, Order, Pet, PhoneWithMaxLength } from './gen/effect/index.ts' + +type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false +type Expect = Value + +type _PetSchemaType = Expect> +type _OrderSchemaType = Expect> +type _OrderDecodedShipDate = Expect, DateTime.Utc>> +type _OrderEncodedShipDate = Expect, string>> + +describe('generated Effect schemas', () => { + test('decodes recursive schemas and strips excess properties', () => { + const decoded = Schema.decodeUnknownSync(Pet)({ + name: 'Milo', + photoUrls: [], + parent: [{ name: 'Luna', photoUrls: [] }], + ignored: true, + }) + expect(decoded).toEqual({ name: 'Milo', photoUrls: [], parent: [{ name: 'Luna', photoUrls: [] }] }) + }) + + test('enforces pattern, length, and numeric constraints', () => { + expect(() => Schema.decodeUnknownSync(Pet)({ name: 'Milo', photoUrls: [], internalId: 'invalid' })).toThrow() + expect(() => Schema.decodeUnknownSync(PhoneWithMaxLength)('+123 1234 5678 9012')).toThrow() + expect(() => Schema.decodeUnknownSync(GetThingsQueryLimit)(101)).toThrow() + }) + + test('keeps format and default metadata annotation-only', () => { + const FormatOnly = Schema.String.annotate({ format: 'email' }) + expect(Schema.decodeUnknownSync(FormatOnly)('not-an-email')).toBe('not-an-email') + expect(Schema.decodeUnknownSync(GetThingsQueryLimit)(undefined)).toBeUndefined() + }) + + test('round-trips encoded date-time strings', () => { + const decoded = Schema.decodeUnknownSync(Order)({ shipDate: '2026-07-14T10:30:00.000Z' }) + if (!decoded.shipDate) throw new Error('Expected shipDate to be decoded') + expect(DateTime.isUtc(decoded.shipDate)).toBe(true) + expect(DateTime.formatIso(decoded.shipDate)).toBe('2026-07-14T10:30:00.000Z') + expect(Schema.encodeUnknownSync(Order)(decoded)).toEqual({ shipDate: '2026-07-14T10:30:00.000Z' }) + }) + + test('round-trips date-only strings without adding a time component', () => { + const DateOnly = Schema.String.pipe( + Schema.decodeTo(Schema.DateValid, { + decode: SchemaGetter.transform((value) => new Date(`${value}T00:00:00.000Z`)), + encode: SchemaGetter.transform((value) => value.toISOString().slice(0, 10)), + }), + ) + const decoded = Schema.decodeUnknownSync(DateOnly)('2026-07-14') + expect(Schema.encodeUnknownSync(DateOnly)(decoded)).toBe('2026-07-14') + }) +}) diff --git a/examples/effect/src/gen/effect/AddPet.ts b/examples/effect/src/gen/effect/AddPet.ts new file mode 100644 index 000000000..f2d205c03 --- /dev/null +++ b/examples/effect/src/gen/effect/AddPet.ts @@ -0,0 +1,53 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { AddPetRequest } from './AddPetRequest' +import { Pet } from './Pet' + +export type AddPetStatus200Json = Pet + +export const AddPetStatus200Json = Schema.suspend((): Schema.Codec => Pet) + +export type AddPetStatus200Xml = Pet + +export const AddPetStatus200Xml = Schema.suspend((): Schema.Codec => Pet) + +export type AddPetStatus200 = AddPetStatus200Json | AddPetStatus200Xml + +export const AddPetStatus200 = Schema.Union([AddPetStatus200Json, AddPetStatus200Xml]) + +export type AddPetStatus405 = { readonly code?: number; readonly message?: string } + +export const AddPetStatus405 = Schema.Struct({ + code: Schema.optionalKey(Schema.Number.check(Schema.isFinite(), Schema.isInt()).annotate({ format: 'int32' })), + message: Schema.optionalKey(Schema.String), +}) + +export type AddPetResponse = AddPetStatus200 + +export const AddPetResponse = AddPetStatus200 + +export type AddPetError = AddPetStatus405 + +export const AddPetError = AddPetStatus405 + +export type AddPetBodyJson = AddPetRequest + +export const AddPetBodyJson = AddPetRequest.annotate({ description: 'Create a new pet in the store' }) + +export type AddPetBodyXml = Pet + +export const AddPetBodyXml = Schema.suspend((): Schema.Codec => Pet).annotate({ description: 'Create a new pet in the store' }) + +export type AddPetBodyFormUrlEncoded = Pet + +export const AddPetBodyFormUrlEncoded = Schema.suspend((): Schema.Codec => Pet).annotate({ + description: 'Create a new pet in the store', +}) + +export type AddPetBody = AddPetBodyJson | AddPetBodyXml | AddPetBodyFormUrlEncoded + +export const AddPetBody = Schema.Union([AddPetBodyJson, AddPetBodyXml, AddPetBodyFormUrlEncoded]) diff --git a/examples/effect/src/gen/effect/AddPetRequest.ts b/examples/effect/src/gen/effect/AddPetRequest.ts new file mode 100644 index 000000000..6926bd127 --- /dev/null +++ b/examples/effect/src/gen/effect/AddPetRequest.ts @@ -0,0 +1,26 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Category } from './Category' +import { Tag } from './Tag' + +export type AddPetRequest = { + readonly id?: bigint + readonly name: string + readonly category?: Category + readonly photoUrls: ReadonlyArray + readonly tags?: ReadonlyArray + readonly status?: 'available' | 'pending' | 'sold' +} + +export const AddPetRequest = Schema.Struct({ + id: Schema.optionalKey(Schema.BigInt.annotate({ format: 'int64', examples: [BigInt('10')] })), + name: Schema.String.annotate({ examples: ['doggie'] }), + category: Schema.optionalKey(Schema.suspend((): Schema.Codec => Category)), + photoUrls: Schema.Array(Schema.String), + tags: Schema.optionalKey(Schema.Array(Tag)), + status: Schema.optionalKey(Schema.Literals(['available', 'pending', 'sold']).annotate({ description: 'pet status in the store' })), +}) diff --git a/examples/effect/src/gen/effect/ApiResponse.ts b/examples/effect/src/gen/effect/ApiResponse.ts new file mode 100644 index 000000000..1473879dd --- /dev/null +++ b/examples/effect/src/gen/effect/ApiResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type ApiResponse = { readonly code?: number; readonly type?: string; readonly message?: string } + +export const ApiResponse = Schema.Struct({ + code: Schema.optionalKey(Schema.Number.check(Schema.isFinite(), Schema.isInt()).annotate({ format: 'int32' })), + type: Schema.optionalKey(Schema.String), + message: Schema.optionalKey(Schema.String), +}) diff --git a/examples/effect/src/gen/effect/Category.ts b/examples/effect/src/gen/effect/Category.ts new file mode 100644 index 000000000..79b2f73bb --- /dev/null +++ b/examples/effect/src/gen/effect/Category.ts @@ -0,0 +1,16 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type Category = { readonly id?: bigint; readonly name?: string; readonly parent?: Category } + +type CategoryEncoded = { readonly id?: bigint; readonly name?: string; readonly parent?: CategoryEncoded } + +export const Category: Schema.Codec = Schema.Struct({ + id: Schema.optionalKey(Schema.BigInt.annotate({ format: 'int64', examples: [BigInt('1')] })), + name: Schema.optionalKey(Schema.String.annotate({ examples: ['Dogs'] })), + parent: Schema.optionalKey(Schema.suspend((): Schema.Codec => Category)), +}) diff --git a/examples/effect/src/gen/effect/CreatePets.ts b/examples/effect/src/gen/effect/CreatePets.ts new file mode 100644 index 000000000..79bcf118d --- /dev/null +++ b/examples/effect/src/gen/effect/CreatePets.ts @@ -0,0 +1,39 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { PetNotFound } from './PetNotFound' + +export type CreatePetsPathUuid = string + +export const CreatePetsPathUuid = Schema.String.annotate({ description: 'UUID' }) + +export type CreatePetsQueryOffset = number | undefined + +export const CreatePetsQueryOffset = Schema.UndefinedOr(Schema.Number.check(Schema.isFinite(), Schema.isInt()).annotate({ description: 'Offset' })) + +export type CreatePetsHeaderXEXAMPLE = 'ONE' | 'TWO' | 'THREE' + +export const CreatePetsHeaderXEXAMPLE = Schema.Literals(['ONE', 'TWO', 'THREE']).annotate({ description: 'Header parameters' }) + +export type CreatePetsStatus201 = unknown + +export const CreatePetsStatus201 = Schema.Unknown + +export type CreatePetsStatusDefault = PetNotFound + +export const CreatePetsStatusDefault = PetNotFound.annotate({ description: 'Pet not found' }) + +export type CreatePetsResponse = CreatePetsStatus201 + +export const CreatePetsResponse = CreatePetsStatus201 + +export type CreatePetsError = CreatePetsStatusDefault + +export const CreatePetsError = CreatePetsStatusDefault + +export type CreatePetsBody = { readonly name: string; readonly tag: string } + +export const CreatePetsBody = Schema.Struct({ name: Schema.String, tag: Schema.String }) diff --git a/examples/effect/src/gen/effect/DeleteOrder.ts b/examples/effect/src/gen/effect/DeleteOrder.ts new file mode 100644 index 000000000..7b52b6865 --- /dev/null +++ b/examples/effect/src/gen/effect/DeleteOrder.ts @@ -0,0 +1,26 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type DeleteOrderPathOrderId = bigint + +export const DeleteOrderPathOrderId = Schema.BigInt.annotate({ format: 'int64', description: 'ID of the order that needs to be deleted' }) + +export type DeleteOrderStatus400 = unknown + +export const DeleteOrderStatus400 = Schema.Unknown + +export type DeleteOrderStatus404 = unknown + +export const DeleteOrderStatus404 = Schema.Unknown + +export type DeleteOrderResponse = unknown + +export const DeleteOrderResponse = Schema.Unknown + +export type DeleteOrderError = DeleteOrderStatus400 | DeleteOrderStatus404 + +export const DeleteOrderError = Schema.Union([DeleteOrderStatus400, DeleteOrderStatus404]) diff --git a/examples/effect/src/gen/effect/DeletePet.ts b/examples/effect/src/gen/effect/DeletePet.ts new file mode 100644 index 000000000..ceadf4222 --- /dev/null +++ b/examples/effect/src/gen/effect/DeletePet.ts @@ -0,0 +1,26 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type DeletePetHeaderApiKey = string | undefined + +export const DeletePetHeaderApiKey = Schema.UndefinedOr(Schema.String) + +export type DeletePetPathPetId = bigint + +export const DeletePetPathPetId = Schema.BigInt.annotate({ format: 'int64', description: 'Pet id to delete' }) + +export type DeletePetStatus400 = unknown + +export const DeletePetStatus400 = Schema.Unknown + +export type DeletePetResponse = unknown + +export const DeletePetResponse = Schema.Unknown + +export type DeletePetError = DeletePetStatus400 + +export const DeletePetError = DeletePetStatus400 diff --git a/examples/effect/src/gen/effect/FindPetsByStatus.ts b/examples/effect/src/gen/effect/FindPetsByStatus.ts new file mode 100644 index 000000000..514c79ca6 --- /dev/null +++ b/examples/effect/src/gen/effect/FindPetsByStatus.ts @@ -0,0 +1,37 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Pet } from './Pet' + +export type FindPetsByStatusQueryStatus = 'available' | 'pending' | 'sold' | undefined + +export const FindPetsByStatusQueryStatus = Schema.UndefinedOr( + Schema.Literals(['available', 'pending', 'sold']).annotate({ description: 'Status values that need to be considered for filter', default: 'available' }), +) + +export type FindPetsByStatusStatus200Json = ReadonlyArray + +export const FindPetsByStatusStatus200Json = Schema.Array(Schema.suspend((): Schema.Codec => Pet)) + +export type FindPetsByStatusStatus200Xml = ReadonlyArray + +export const FindPetsByStatusStatus200Xml = Schema.Array(Schema.suspend((): Schema.Codec => Pet)) + +export type FindPetsByStatusStatus200 = FindPetsByStatusStatus200Json | FindPetsByStatusStatus200Xml + +export const FindPetsByStatusStatus200 = Schema.Union([FindPetsByStatusStatus200Json, FindPetsByStatusStatus200Xml]) + +export type FindPetsByStatusStatus400 = unknown + +export const FindPetsByStatusStatus400 = Schema.Unknown + +export type FindPetsByStatusResponse = FindPetsByStatusStatus200 + +export const FindPetsByStatusResponse = FindPetsByStatusStatus200 + +export type FindPetsByStatusError = FindPetsByStatusStatus400 + +export const FindPetsByStatusError = FindPetsByStatusStatus400 diff --git a/examples/effect/src/gen/effect/FindPetsByTags.ts b/examples/effect/src/gen/effect/FindPetsByTags.ts new file mode 100644 index 000000000..968dd7dfd --- /dev/null +++ b/examples/effect/src/gen/effect/FindPetsByTags.ts @@ -0,0 +1,47 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Pet } from './Pet' + +export type FindPetsByTagsQueryTags = ReadonlyArray | undefined + +export const FindPetsByTagsQueryTags = Schema.UndefinedOr(Schema.Array(Schema.String).annotate({ description: 'Tags to filter by' })) + +export type FindPetsByTagsQueryPage = string | undefined + +export const FindPetsByTagsQueryPage = Schema.UndefinedOr(Schema.String.annotate({ description: 'to request with required page number or pagination' })) + +export type FindPetsByTagsQueryPageSize = string | undefined + +export const FindPetsByTagsQueryPageSize = Schema.UndefinedOr(Schema.String.annotate({ description: 'to request with required page size' })) + +export type FindPetsByTagsHeaderXEXAMPLE = 'ONE' | 'TWO' | 'THREE' + +export const FindPetsByTagsHeaderXEXAMPLE = Schema.Literals(['ONE', 'TWO', 'THREE']).annotate({ description: 'Header parameters' }) + +export type FindPetsByTagsStatus200Json = ReadonlyArray + +export const FindPetsByTagsStatus200Json = Schema.Array(Schema.suspend((): Schema.Codec => Pet)) + +export type FindPetsByTagsStatus200Xml = ReadonlyArray + +export const FindPetsByTagsStatus200Xml = Schema.Array(Schema.suspend((): Schema.Codec => Pet)) + +export type FindPetsByTagsStatus200 = FindPetsByTagsStatus200Json | FindPetsByTagsStatus200Xml + +export const FindPetsByTagsStatus200 = Schema.Union([FindPetsByTagsStatus200Json, FindPetsByTagsStatus200Xml]) + +export type FindPetsByTagsStatus400 = unknown + +export const FindPetsByTagsStatus400 = Schema.Unknown + +export type FindPetsByTagsResponse = FindPetsByTagsStatus200 + +export const FindPetsByTagsResponse = FindPetsByTagsStatus200 + +export type FindPetsByTagsError = FindPetsByTagsStatus400 + +export const FindPetsByTagsError = FindPetsByTagsStatus400 diff --git a/examples/effect/src/gen/effect/GetInventory.ts b/examples/effect/src/gen/effect/GetInventory.ts new file mode 100644 index 000000000..5c558ce88 --- /dev/null +++ b/examples/effect/src/gen/effect/GetInventory.ts @@ -0,0 +1,16 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type GetInventoryStatus200 = {} & Readonly> + +export const GetInventoryStatus200 = Schema.StructWithRest(Schema.Struct({}), [ + Schema.Record(Schema.String, Schema.Number.check(Schema.isFinite(), Schema.isInt()).annotate({ format: 'int32' })), +]) + +export type GetInventoryResponse = GetInventoryStatus200 + +export const GetInventoryResponse = GetInventoryStatus200 diff --git a/examples/effect/src/gen/effect/GetOrderById.ts b/examples/effect/src/gen/effect/GetOrderById.ts new file mode 100644 index 000000000..6257e3b67 --- /dev/null +++ b/examples/effect/src/gen/effect/GetOrderById.ts @@ -0,0 +1,39 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Order } from './Order' + +export type GetOrderByIdPathOrderId = bigint + +export const GetOrderByIdPathOrderId = Schema.BigInt.annotate({ format: 'int64', description: 'ID of order that needs to be fetched' }) + +export type GetOrderByIdStatus200Json = Order + +export const GetOrderByIdStatus200Json = Order + +export type GetOrderByIdStatus200Xml = Order + +export const GetOrderByIdStatus200Xml = Order + +export type GetOrderByIdStatus200 = GetOrderByIdStatus200Json | GetOrderByIdStatus200Xml + +export const GetOrderByIdStatus200 = Schema.Union([GetOrderByIdStatus200Json, GetOrderByIdStatus200Xml]) + +export type GetOrderByIdStatus400 = unknown + +export const GetOrderByIdStatus400 = Schema.Unknown + +export type GetOrderByIdStatus404 = unknown + +export const GetOrderByIdStatus404 = Schema.Unknown + +export type GetOrderByIdResponse = GetOrderByIdStatus200 + +export const GetOrderByIdResponse = GetOrderByIdStatus200 + +export type GetOrderByIdError = GetOrderByIdStatus400 | GetOrderByIdStatus404 + +export const GetOrderByIdError = Schema.Union([GetOrderByIdStatus400, GetOrderByIdStatus404]) diff --git a/examples/effect/src/gen/effect/GetPetById.ts b/examples/effect/src/gen/effect/GetPetById.ts new file mode 100644 index 000000000..e377b8389 --- /dev/null +++ b/examples/effect/src/gen/effect/GetPetById.ts @@ -0,0 +1,39 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Pet } from './Pet' + +export type GetPetByIdPathPetId = bigint + +export const GetPetByIdPathPetId = Schema.BigInt.annotate({ format: 'int64', description: 'ID of pet to return' }) + +export type GetPetByIdStatus200Json = Pet + +export const GetPetByIdStatus200Json = Schema.suspend((): Schema.Codec => Pet) + +export type GetPetByIdStatus200Xml = Pet + +export const GetPetByIdStatus200Xml = Schema.suspend((): Schema.Codec => Pet) + +export type GetPetByIdStatus200 = GetPetByIdStatus200Json | GetPetByIdStatus200Xml + +export const GetPetByIdStatus200 = Schema.Union([GetPetByIdStatus200Json, GetPetByIdStatus200Xml]) + +export type GetPetByIdStatus400 = unknown + +export const GetPetByIdStatus400 = Schema.Unknown + +export type GetPetByIdStatus404 = unknown + +export const GetPetByIdStatus404 = Schema.Unknown + +export type GetPetByIdResponse = GetPetByIdStatus200 + +export const GetPetByIdResponse = GetPetByIdStatus200 + +export type GetPetByIdError = GetPetByIdStatus400 | GetPetByIdStatus404 + +export const GetPetByIdError = Schema.Union([GetPetByIdStatus400, GetPetByIdStatus404]) diff --git a/examples/effect/src/gen/effect/GetThings.ts b/examples/effect/src/gen/effect/GetThings.ts new file mode 100644 index 000000000..82d16af85 --- /dev/null +++ b/examples/effect/src/gen/effect/GetThings.ts @@ -0,0 +1,38 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { PetNotFound } from './PetNotFound' + +export type GetThingsQueryLimit = number | undefined + +export const GetThingsQueryLimit = Schema.UndefinedOr( + Schema.Number.check(Schema.isFinite(), Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(100)).annotate({ + description: 'Maximum number of things to return', + default: 100, + }), +) + +export type GetThingsQuerySkip = number | undefined + +export const GetThingsQuerySkip = Schema.UndefinedOr( + Schema.Number.check(Schema.isFinite(), Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)).annotate({ description: 'Number of things to skip', default: 0 }), +) + +export type GetThingsStatus201 = unknown + +export const GetThingsStatus201 = Schema.Unknown + +export type GetThingsStatusDefault = PetNotFound + +export const GetThingsStatusDefault = PetNotFound.annotate({ description: 'Pet not found' }) + +export type GetThingsResponse = GetThingsStatus201 + +export const GetThingsResponse = GetThingsStatus201 + +export type GetThingsError = GetThingsStatusDefault + +export const GetThingsError = GetThingsStatusDefault diff --git a/examples/effect/src/gen/effect/Order.ts b/examples/effect/src/gen/effect/Order.ts new file mode 100644 index 000000000..9f433308c --- /dev/null +++ b/examples/effect/src/gen/effect/Order.ts @@ -0,0 +1,29 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as DateTime from 'effect/DateTime' +import * as Schema from 'effect/Schema' + +export type Order = { + readonly id?: bigint + readonly petId?: bigint + readonly quantity?: number + readonly shipDate?: DateTime.Utc + readonly status?: 'placed' | 'approved' | 'delivered' + readonly http_status?: 200 | 400 | 500 + readonly value?: 0 | 1 | 2 | 3 | 3.5 | 4 + readonly complete?: boolean +} + +export const Order = Schema.Struct({ + id: Schema.optionalKey(Schema.BigInt.annotate({ format: 'int64', examples: [BigInt('10')] })), + petId: Schema.optionalKey(Schema.BigInt.annotate({ format: 'int64', examples: [BigInt('198772')] })), + quantity: Schema.optionalKey(Schema.Number.check(Schema.isFinite(), Schema.isInt()).annotate({ format: 'int32', examples: [7] })), + shipDate: Schema.optionalKey(Schema.DateTimeUtcFromString.annotate({ format: 'date-time' })), + status: Schema.optionalKey(Schema.Literals(['placed', 'approved', 'delivered']).annotate({ description: 'Order Status', examples: ['approved'] })), + http_status: Schema.optionalKey(Schema.Literals([200, 400, 500]).annotate({ description: 'HTTP Status', examples: [200] })), + value: Schema.optionalKey(Schema.Literals([0, 1, 2, 3, 3.5, 4]).annotate({ description: 'Price', examples: [2] })), + complete: Schema.optionalKey(Schema.Boolean), +}) diff --git a/examples/effect/src/gen/effect/Pet.ts b/examples/effect/src/gen/effect/Pet.ts new file mode 100644 index 000000000..9a38eaecf --- /dev/null +++ b/examples/effect/src/gen/effect/Pet.ts @@ -0,0 +1,41 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Category } from './Category' +import { Tag } from './Tag' + +export type Pet = { + readonly id?: bigint + readonly parent?: ReadonlyArray + readonly internalId?: string + readonly name: string + readonly category?: Category + readonly photoUrls: ReadonlyArray + readonly tags?: ReadonlyArray + readonly status?: 'available' | 'pending' | 'sold' +} + +type PetEncoded = { + readonly id?: bigint + readonly parent?: ReadonlyArray + readonly internalId?: string + readonly name: string + readonly category?: typeof Category.Encoded + readonly photoUrls: ReadonlyArray + readonly tags?: ReadonlyArray + readonly status?: 'available' | 'pending' | 'sold' +} + +export const Pet: Schema.Codec = Schema.Struct({ + id: Schema.optionalKey(Schema.BigInt.annotate({ format: 'int64', examples: [BigInt('10')] })), + parent: Schema.optionalKey(Schema.Array(Schema.suspend((): Schema.Codec => Pet))), + internalId: Schema.optionalKey(Schema.String.check(Schema.isPattern(new RegExp('^[0-9]{1,19}$'))).annotate({ examples: ['10'] })), + name: Schema.String.annotate({ examples: ['doggie'] }), + category: Schema.optionalKey(Schema.suspend((): Schema.Codec => Category)), + photoUrls: Schema.Array(Schema.String), + tags: Schema.optionalKey(Schema.Array(Tag)), + status: Schema.optionalKey(Schema.Literals(['available', 'pending', 'sold']).annotate({ description: 'pet status in the store' })), +}) diff --git a/examples/effect/src/gen/effect/PetNotFound.ts b/examples/effect/src/gen/effect/PetNotFound.ts new file mode 100644 index 000000000..09a8a5a27 --- /dev/null +++ b/examples/effect/src/gen/effect/PetNotFound.ts @@ -0,0 +1,13 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type PetNotFound = { readonly code?: number; readonly message?: string } + +export const PetNotFound = Schema.Struct({ + code: Schema.optionalKey(Schema.Number.check(Schema.isFinite(), Schema.isInt()).annotate({ format: 'int32' })), + message: Schema.optionalKey(Schema.String), +}) diff --git a/examples/effect/src/gen/effect/PhoneNumber.ts b/examples/effect/src/gen/effect/PhoneNumber.ts new file mode 100644 index 000000000..f101a0f8b --- /dev/null +++ b/examples/effect/src/gen/effect/PhoneNumber.ts @@ -0,0 +1,10 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type PhoneNumber = string + +export const PhoneNumber = Schema.String.check(Schema.isPattern(new RegExp('^(\\+\\d{1,3}[-\\s]?)?\\(?(?:\\d{1,4})\\)?[-\\s]?\\d{1,4}[-\\s]?\\d{1,9}$'))) diff --git a/examples/effect/src/gen/effect/PhoneWithMaxLength.ts b/examples/effect/src/gen/effect/PhoneWithMaxLength.ts new file mode 100644 index 000000000..5b2b6d071 --- /dev/null +++ b/examples/effect/src/gen/effect/PhoneWithMaxLength.ts @@ -0,0 +1,13 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { PhoneNumber } from './PhoneNumber' + +export type PhoneWithMaxLength = PhoneNumber & string + +export const PhoneWithMaxLength = Schema.declare((value): value is PhoneNumber & string => + [PhoneNumber, Schema.String.check(Schema.isMaxLength(15))].every((schema) => Schema.is(schema)(value)), +) diff --git a/examples/effect/src/gen/effect/PhoneWithMaxLengthExplicit.ts b/examples/effect/src/gen/effect/PhoneWithMaxLengthExplicit.ts new file mode 100644 index 000000000..a36c65199 --- /dev/null +++ b/examples/effect/src/gen/effect/PhoneWithMaxLengthExplicit.ts @@ -0,0 +1,13 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { PhoneNumber } from './PhoneNumber' + +export type PhoneWithMaxLengthExplicit = PhoneNumber & string + +export const PhoneWithMaxLengthExplicit = Schema.declare((value): value is PhoneNumber & string => + [PhoneNumber, Schema.String.check(Schema.isMaxLength(15))].every((schema) => Schema.is(schema)(value)), +) diff --git a/examples/effect/src/gen/effect/PlaceOrder.ts b/examples/effect/src/gen/effect/PlaceOrder.ts new file mode 100644 index 000000000..2cb521829 --- /dev/null +++ b/examples/effect/src/gen/effect/PlaceOrder.ts @@ -0,0 +1,39 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Order } from './Order' + +export type PlaceOrderStatus200 = Order + +export const PlaceOrderStatus200 = Order + +export type PlaceOrderStatus405 = unknown + +export const PlaceOrderStatus405 = Schema.Unknown + +export type PlaceOrderResponse = PlaceOrderStatus200 + +export const PlaceOrderResponse = PlaceOrderStatus200 + +export type PlaceOrderError = PlaceOrderStatus405 + +export const PlaceOrderError = PlaceOrderStatus405 + +export type PlaceOrderBodyJson = Order | undefined + +export const PlaceOrderBodyJson = Schema.UndefinedOr(Order) + +export type PlaceOrderBodyXml = Order | undefined + +export const PlaceOrderBodyXml = Schema.UndefinedOr(Order) + +export type PlaceOrderBodyFormUrlEncoded = Order | undefined + +export const PlaceOrderBodyFormUrlEncoded = Schema.UndefinedOr(Order) + +export type PlaceOrderBody = PlaceOrderBodyJson | PlaceOrderBodyXml | PlaceOrderBodyFormUrlEncoded + +export const PlaceOrderBody = Schema.Union([PlaceOrderBodyJson, PlaceOrderBodyXml, PlaceOrderBodyFormUrlEncoded]) diff --git a/examples/effect/src/gen/effect/PlaceOrderPatch.ts b/examples/effect/src/gen/effect/PlaceOrderPatch.ts new file mode 100644 index 000000000..9f52a480d --- /dev/null +++ b/examples/effect/src/gen/effect/PlaceOrderPatch.ts @@ -0,0 +1,39 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Order } from './Order' + +export type PlaceOrderPatchStatus200 = Order + +export const PlaceOrderPatchStatus200 = Order + +export type PlaceOrderPatchStatus405 = unknown + +export const PlaceOrderPatchStatus405 = Schema.Unknown + +export type PlaceOrderPatchResponse = PlaceOrderPatchStatus200 + +export const PlaceOrderPatchResponse = PlaceOrderPatchStatus200 + +export type PlaceOrderPatchError = PlaceOrderPatchStatus405 + +export const PlaceOrderPatchError = PlaceOrderPatchStatus405 + +export type PlaceOrderPatchBodyJson = Order | undefined + +export const PlaceOrderPatchBodyJson = Schema.UndefinedOr(Order) + +export type PlaceOrderPatchBodyXml = Order | undefined + +export const PlaceOrderPatchBodyXml = Schema.UndefinedOr(Order) + +export type PlaceOrderPatchBodyFormUrlEncoded = Order | undefined + +export const PlaceOrderPatchBodyFormUrlEncoded = Schema.UndefinedOr(Order) + +export type PlaceOrderPatchBody = PlaceOrderPatchBodyJson | PlaceOrderPatchBodyXml | PlaceOrderPatchBodyFormUrlEncoded + +export const PlaceOrderPatchBody = Schema.Union([PlaceOrderPatchBodyJson, PlaceOrderPatchBodyXml, PlaceOrderPatchBodyFormUrlEncoded]) diff --git a/examples/effect/src/gen/effect/Tag.ts b/examples/effect/src/gen/effect/Tag.ts new file mode 100644 index 000000000..5a6e28bc6 --- /dev/null +++ b/examples/effect/src/gen/effect/Tag.ts @@ -0,0 +1,10 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type Tag = { readonly id?: bigint; readonly name?: string } + +export const Tag = Schema.Struct({ id: Schema.optionalKey(Schema.BigInt.annotate({ format: 'int64' })), name: Schema.optionalKey(Schema.String) }) diff --git a/examples/effect/src/gen/effect/UpdatePet.ts b/examples/effect/src/gen/effect/UpdatePet.ts new file mode 100644 index 000000000..b058cc629 --- /dev/null +++ b/examples/effect/src/gen/effect/UpdatePet.ts @@ -0,0 +1,61 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Pet } from './Pet' + +export type UpdatePetStatus200Json = Pet + +export const UpdatePetStatus200Json = Schema.suspend((): Schema.Codec => Pet) + +export type UpdatePetStatus200Xml = Pet + +export const UpdatePetStatus200Xml = Schema.suspend((): Schema.Codec => Pet) + +export type UpdatePetStatus200 = UpdatePetStatus200Json | UpdatePetStatus200Xml + +export const UpdatePetStatus200 = Schema.Union([UpdatePetStatus200Json, UpdatePetStatus200Xml]) + +export type UpdatePetStatus400 = unknown + +export const UpdatePetStatus400 = Schema.Unknown + +export type UpdatePetStatus404 = unknown + +export const UpdatePetStatus404 = Schema.Unknown + +export type UpdatePetStatus405 = unknown + +export const UpdatePetStatus405 = Schema.Unknown + +export type UpdatePetResponse = UpdatePetStatus200 + +export const UpdatePetResponse = UpdatePetStatus200 + +export type UpdatePetError = UpdatePetStatus400 | UpdatePetStatus404 | UpdatePetStatus405 + +export const UpdatePetError = Schema.Union([UpdatePetStatus400, UpdatePetStatus404, UpdatePetStatus405]) + +export type UpdatePetBodyJson = Pet + +export const UpdatePetBodyJson = Schema.suspend((): Schema.Codec => Pet).annotate({ + description: 'Update an existent pet in the store', +}) + +export type UpdatePetBodyXml = Pet + +export const UpdatePetBodyXml = Schema.suspend((): Schema.Codec => Pet).annotate({ + description: 'Update an existent pet in the store', +}) + +export type UpdatePetBodyFormUrlEncoded = Pet + +export const UpdatePetBodyFormUrlEncoded = Schema.suspend((): Schema.Codec => Pet).annotate({ + description: 'Update an existent pet in the store', +}) + +export type UpdatePetBody = UpdatePetBodyJson | UpdatePetBodyXml | UpdatePetBodyFormUrlEncoded + +export const UpdatePetBody = Schema.Union([UpdatePetBodyJson, UpdatePetBodyXml, UpdatePetBodyFormUrlEncoded]) diff --git a/examples/effect/src/gen/effect/UpdatePetWithForm.ts b/examples/effect/src/gen/effect/UpdatePetWithForm.ts new file mode 100644 index 000000000..4128cc7bf --- /dev/null +++ b/examples/effect/src/gen/effect/UpdatePetWithForm.ts @@ -0,0 +1,30 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type UpdatePetWithFormPathPetId = bigint + +export const UpdatePetWithFormPathPetId = Schema.BigInt.annotate({ format: 'int64', description: 'ID of pet that needs to be updated' }) + +export type UpdatePetWithFormQueryName = string | undefined + +export const UpdatePetWithFormQueryName = Schema.UndefinedOr(Schema.String.annotate({ description: 'Name of pet that needs to be updated' })) + +export type UpdatePetWithFormQueryStatus = string | undefined + +export const UpdatePetWithFormQueryStatus = Schema.UndefinedOr(Schema.String.annotate({ description: 'Status of pet that needs to be updated' })) + +export type UpdatePetWithFormStatus405 = unknown + +export const UpdatePetWithFormStatus405 = Schema.Unknown + +export type UpdatePetWithFormResponse = unknown + +export const UpdatePetWithFormResponse = Schema.Unknown + +export type UpdatePetWithFormError = UpdatePetWithFormStatus405 + +export const UpdatePetWithFormError = UpdatePetWithFormStatus405 diff --git a/examples/effect/src/gen/effect/UploadFile.ts b/examples/effect/src/gen/effect/UploadFile.ts new file mode 100644 index 000000000..9008b1f6e --- /dev/null +++ b/examples/effect/src/gen/effect/UploadFile.ts @@ -0,0 +1,27 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { ApiResponse } from './ApiResponse' + +export type UploadFilePathPetId = bigint + +export const UploadFilePathPetId = Schema.BigInt.annotate({ format: 'int64', description: 'ID of pet to update' }) + +export type UploadFileQueryAdditionalMetadata = string | undefined + +export const UploadFileQueryAdditionalMetadata = Schema.UndefinedOr(Schema.String.annotate({ description: 'Additional Metadata' })) + +export type UploadFileStatus200 = ApiResponse + +export const UploadFileStatus200 = ApiResponse + +export type UploadFileResponse = UploadFileStatus200 + +export const UploadFileResponse = UploadFileStatus200 + +export type UploadFileBody = Blob | undefined + +export const UploadFileBody = Schema.UndefinedOr(Schema.instanceOf(Blob)) diff --git a/examples/effect/src/gen/effect/index.ts b/examples/effect/src/gen/effect/index.ts new file mode 100644 index 000000000..20c63cf46 --- /dev/null +++ b/examples/effect/src/gen/effect/index.ts @@ -0,0 +1,120 @@ +export { + AddPetBody, + AddPetBodyFormUrlEncoded, + AddPetBodyJson, + AddPetBodyXml, + AddPetError, + AddPetResponse, + AddPetStatus200, + AddPetStatus200Json, + AddPetStatus200Xml, + AddPetStatus405, +} from './AddPet' +export { AddPetRequest } from './AddPetRequest' +export { ApiResponse } from './ApiResponse' +export { Category } from './Category' +export { + CreatePetsBody, + CreatePetsError, + CreatePetsHeaderXEXAMPLE, + CreatePetsPathUuid, + CreatePetsQueryOffset, + CreatePetsResponse, + CreatePetsStatus201, + CreatePetsStatusDefault, +} from './CreatePets' +export { DeleteOrderError, DeleteOrderPathOrderId, DeleteOrderResponse, DeleteOrderStatus400, DeleteOrderStatus404 } from './DeleteOrder' +export { DeletePetError, DeletePetHeaderApiKey, DeletePetPathPetId, DeletePetResponse, DeletePetStatus400 } from './DeletePet' +export { + FindPetsByStatusError, + FindPetsByStatusQueryStatus, + FindPetsByStatusResponse, + FindPetsByStatusStatus200, + FindPetsByStatusStatus200Json, + FindPetsByStatusStatus200Xml, + FindPetsByStatusStatus400, +} from './FindPetsByStatus' +export { + FindPetsByTagsError, + FindPetsByTagsHeaderXEXAMPLE, + FindPetsByTagsQueryPage, + FindPetsByTagsQueryPageSize, + FindPetsByTagsQueryTags, + FindPetsByTagsResponse, + FindPetsByTagsStatus200, + FindPetsByTagsStatus200Json, + FindPetsByTagsStatus200Xml, + FindPetsByTagsStatus400, +} from './FindPetsByTags' +export { GetInventoryResponse, GetInventoryStatus200 } from './GetInventory' +export { + GetOrderByIdError, + GetOrderByIdPathOrderId, + GetOrderByIdResponse, + GetOrderByIdStatus200, + GetOrderByIdStatus200Json, + GetOrderByIdStatus200Xml, + GetOrderByIdStatus400, + GetOrderByIdStatus404, +} from './GetOrderById' +export { + GetPetByIdError, + GetPetByIdPathPetId, + GetPetByIdResponse, + GetPetByIdStatus200, + GetPetByIdStatus200Json, + GetPetByIdStatus200Xml, + GetPetByIdStatus400, + GetPetByIdStatus404, +} from './GetPetById' +export { GetThingsError, GetThingsQueryLimit, GetThingsQuerySkip, GetThingsResponse, GetThingsStatus201, GetThingsStatusDefault } from './GetThings' +export { Order } from './Order' +export { Pet } from './Pet' +export { PetNotFound } from './PetNotFound' +export { PhoneNumber } from './PhoneNumber' +export { PhoneWithMaxLength } from './PhoneWithMaxLength' +export { PhoneWithMaxLengthExplicit } from './PhoneWithMaxLengthExplicit' +export { + PlaceOrderBody, + PlaceOrderBodyFormUrlEncoded, + PlaceOrderBodyJson, + PlaceOrderBodyXml, + PlaceOrderError, + PlaceOrderResponse, + PlaceOrderStatus200, + PlaceOrderStatus405, +} from './PlaceOrder' +export { + PlaceOrderPatchBody, + PlaceOrderPatchBodyFormUrlEncoded, + PlaceOrderPatchBodyJson, + PlaceOrderPatchBodyXml, + PlaceOrderPatchError, + PlaceOrderPatchResponse, + PlaceOrderPatchStatus200, + PlaceOrderPatchStatus405, +} from './PlaceOrderPatch' +export { Tag } from './Tag' +export { + UpdatePetBody, + UpdatePetBodyFormUrlEncoded, + UpdatePetBodyJson, + UpdatePetBodyXml, + UpdatePetError, + UpdatePetResponse, + UpdatePetStatus200, + UpdatePetStatus200Json, + UpdatePetStatus200Xml, + UpdatePetStatus400, + UpdatePetStatus404, + UpdatePetStatus405, +} from './UpdatePet' +export { + UpdatePetWithFormError, + UpdatePetWithFormPathPetId, + UpdatePetWithFormQueryName, + UpdatePetWithFormQueryStatus, + UpdatePetWithFormResponse, + UpdatePetWithFormStatus405, +} from './UpdatePetWithForm' +export { UploadFileBody, UploadFilePathPetId, UploadFileQueryAdditionalMetadata, UploadFileResponse, UploadFileStatus200 } from './UploadFile' diff --git a/examples/effect/src/gen/index.ts b/examples/effect/src/gen/index.ts new file mode 100644 index 000000000..ebe380b49 --- /dev/null +++ b/examples/effect/src/gen/index.ts @@ -0,0 +1,120 @@ +export { + AddPetBody, + AddPetBodyFormUrlEncoded, + AddPetBodyJson, + AddPetBodyXml, + AddPetError, + AddPetResponse, + AddPetStatus200, + AddPetStatus200Json, + AddPetStatus200Xml, + AddPetStatus405, +} from './effect/AddPet' +export { AddPetRequest } from './effect/AddPetRequest' +export { ApiResponse } from './effect/ApiResponse' +export { Category } from './effect/Category' +export { + CreatePetsBody, + CreatePetsError, + CreatePetsHeaderXEXAMPLE, + CreatePetsPathUuid, + CreatePetsQueryOffset, + CreatePetsResponse, + CreatePetsStatus201, + CreatePetsStatusDefault, +} from './effect/CreatePets' +export { DeleteOrderError, DeleteOrderPathOrderId, DeleteOrderResponse, DeleteOrderStatus400, DeleteOrderStatus404 } from './effect/DeleteOrder' +export { DeletePetError, DeletePetHeaderApiKey, DeletePetPathPetId, DeletePetResponse, DeletePetStatus400 } from './effect/DeletePet' +export { + FindPetsByStatusError, + FindPetsByStatusQueryStatus, + FindPetsByStatusResponse, + FindPetsByStatusStatus200, + FindPetsByStatusStatus200Json, + FindPetsByStatusStatus200Xml, + FindPetsByStatusStatus400, +} from './effect/FindPetsByStatus' +export { + FindPetsByTagsError, + FindPetsByTagsHeaderXEXAMPLE, + FindPetsByTagsQueryPage, + FindPetsByTagsQueryPageSize, + FindPetsByTagsQueryTags, + FindPetsByTagsResponse, + FindPetsByTagsStatus200, + FindPetsByTagsStatus200Json, + FindPetsByTagsStatus200Xml, + FindPetsByTagsStatus400, +} from './effect/FindPetsByTags' +export { GetInventoryResponse, GetInventoryStatus200 } from './effect/GetInventory' +export { + GetOrderByIdError, + GetOrderByIdPathOrderId, + GetOrderByIdResponse, + GetOrderByIdStatus200, + GetOrderByIdStatus200Json, + GetOrderByIdStatus200Xml, + GetOrderByIdStatus400, + GetOrderByIdStatus404, +} from './effect/GetOrderById' +export { + GetPetByIdError, + GetPetByIdPathPetId, + GetPetByIdResponse, + GetPetByIdStatus200, + GetPetByIdStatus200Json, + GetPetByIdStatus200Xml, + GetPetByIdStatus400, + GetPetByIdStatus404, +} from './effect/GetPetById' +export { GetThingsError, GetThingsQueryLimit, GetThingsQuerySkip, GetThingsResponse, GetThingsStatus201, GetThingsStatusDefault } from './effect/GetThings' +export { Order } from './effect/Order' +export { Pet } from './effect/Pet' +export { PetNotFound } from './effect/PetNotFound' +export { PhoneNumber } from './effect/PhoneNumber' +export { PhoneWithMaxLength } from './effect/PhoneWithMaxLength' +export { PhoneWithMaxLengthExplicit } from './effect/PhoneWithMaxLengthExplicit' +export { + PlaceOrderBody, + PlaceOrderBodyFormUrlEncoded, + PlaceOrderBodyJson, + PlaceOrderBodyXml, + PlaceOrderError, + PlaceOrderResponse, + PlaceOrderStatus200, + PlaceOrderStatus405, +} from './effect/PlaceOrder' +export { + PlaceOrderPatchBody, + PlaceOrderPatchBodyFormUrlEncoded, + PlaceOrderPatchBodyJson, + PlaceOrderPatchBodyXml, + PlaceOrderPatchError, + PlaceOrderPatchResponse, + PlaceOrderPatchStatus200, + PlaceOrderPatchStatus405, +} from './effect/PlaceOrderPatch' +export { Tag } from './effect/Tag' +export { + UpdatePetBody, + UpdatePetBodyFormUrlEncoded, + UpdatePetBodyJson, + UpdatePetBodyXml, + UpdatePetError, + UpdatePetResponse, + UpdatePetStatus200, + UpdatePetStatus200Json, + UpdatePetStatus200Xml, + UpdatePetStatus400, + UpdatePetStatus404, + UpdatePetStatus405, +} from './effect/UpdatePet' +export { + UpdatePetWithFormError, + UpdatePetWithFormPathPetId, + UpdatePetWithFormQueryName, + UpdatePetWithFormQueryStatus, + UpdatePetWithFormResponse, + UpdatePetWithFormStatus405, +} from './effect/UpdatePetWithForm' +export { UploadFileBody, UploadFilePathPetId, UploadFileQueryAdditionalMetadata, UploadFileResponse, UploadFileStatus200 } from './effect/UploadFile' diff --git a/examples/effect/src/index.ts b/examples/effect/src/index.ts new file mode 100644 index 000000000..94020d842 --- /dev/null +++ b/examples/effect/src/index.ts @@ -0,0 +1 @@ +export * from './gen/effect/index.ts' diff --git a/examples/effect/tsconfig.json b/examples/effect/tsconfig.json new file mode 100644 index 000000000..c8f6fbff5 --- /dev/null +++ b/examples/effect/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ES2020", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["./src/**/*", "kubb.config.js"] +} diff --git a/packages/plugin-effect/CHANGELOG.md b/packages/plugin-effect/CHANGELOG.md new file mode 100644 index 000000000..c0c73d0c5 --- /dev/null +++ b/packages/plugin-effect/CHANGELOG.md @@ -0,0 +1 @@ +# @kubb/plugin-effect diff --git a/packages/plugin-effect/README.md b/packages/plugin-effect/README.md new file mode 100644 index 000000000..196b52387 --- /dev/null +++ b/packages/plugin-effect/README.md @@ -0,0 +1,56 @@ +
+ + Kubb banner + + +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![Stars][stars-src]][stars-href] +[![License][license-src]][license-href] +[![Node][node-src]][node-href] +
+ +# @kubb/plugin-effect + +`@kubb/plugin-effect` generates Effect v4 schemas and matching TypeScript types from OpenAPI. The beta currently targets `effect@4.0.0-beta.98`. + +## Installation + +```bash +pnpm add -D @kubb/plugin-effect@beta +pnpm add effect@4.0.0-beta.98 +``` + +## Usage + +```ts +import { pluginEffect } from '@kubb/plugin-effect' +import { defineConfig } from 'kubb/config' + +export default defineConfig({ + input: './petStore.yaml', + output: { path: './src/gen' }, + plugins: [pluginEffect()], +}) +``` + +The plugin generates both `export type Pet` and `export const Pet`. Do not combine the default `plugin-effect` and `plugin-ts` outputs in the same barrel. Use separate output paths or a custom Effect resolver when both plugins are required. + +## Documentation + +See the [Effect plugin documentation](https://kubb.dev/plugins/plugin-effect) for options and examples. + +## License + +[MIT](https://github.com/kubb-labs/plugins/blob/main/LICENSE) + +[npm-version-src]: https://shieldcn.dev/npm/v/@kubb/plugin-effect.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[npm-version-href]: https://npmx.dev/package/@kubb/plugin-effect +[npm-downloads-src]: https://shieldcn.dev/npm/dm/@kubb/plugin-effect.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[npm-downloads-href]: https://npmx.dev/package/@kubb/plugin-effect +[stars-src]: https://shieldcn.dev/github/stars/kubb-labs/kubb.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[stars-href]: https://github.com/kubb-labs/kubb +[license-src]: https://shieldcn.dev/npm/license/@kubb/plugin-effect.svg?variant=secondary&size=xs&theme=zinc +[license-href]: https://github.com/kubb-labs/kubb/blob/main/LICENSE +[node-src]: https://shieldcn.dev/npm/node/@kubb/plugin-effect.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[node-href]: https://npmx.dev/package/@kubb/plugin-effect diff --git a/packages/plugin-effect/package.json b/packages/plugin-effect/package.json new file mode 100644 index 000000000..5f6c6e2f1 --- /dev/null +++ b/packages/plugin-effect/package.json @@ -0,0 +1,69 @@ +{ + "name": "@kubb/plugin-effect", + "version": "5.0.0-beta.95", + "description": "Generate Effect v4 schemas and TypeScript types from OpenAPI with Kubb.", + "keywords": [ + "code-generation", + "codegen", + "effect", + "kubb", + "openapi", + "runtime-validation", + "schema", + "typescript", + "validation" + ], + "license": "MIT", + "author": "stijnvanhulle", + "repository": { + "type": "git", + "url": "git+https://github.com/kubb-labs/plugins.git", + "directory": "packages/plugin-effect" + }, + "files": [ + "dist", + "!/**/**.test.**", + "!/**/__tests__/**", + "!/**/__snapshots__/**" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsdown", + "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", + "lint": "oxlint .", + "lint:fix": "oxlint --fix .", + "release": "pnpm publish --no-git-check", + "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", + "release:stage": "pnpm stage publish --no-git-check", + "start": "tsdown --watch", + "test": "vitest --passWithNoTests", + "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" + }, + "devDependencies": { + "@internals/shared": "workspace:*", + "@internals/utils": "workspace:*", + "effect": "catalog:", + "kubb": "catalog:" + }, + "peerDependencies": { + "kubb": "catalog:" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/plugin-effect/src/components/EffectSchema.tsx b/packages/plugin-effect/src/components/EffectSchema.tsx new file mode 100644 index 000000000..9c1d39e63 --- /dev/null +++ b/packages/plugin-effect/src/components/EffectSchema.tsx @@ -0,0 +1,41 @@ +import type { ast } from 'kubb/kit' +import { Const, File, Type } from 'kubb/jsx' +import type { KubbReactNode } from 'kubb/jsx' +import type { PrinterEffectFactory } from '../printers/printerEffect.ts' + +type Props = { + name: string + node: ast.SchemaNode + printer: ast.Printer + cyclic?: boolean +} + +/** + * Renders a same-name Effect schema value and decoded TypeScript type. + */ +export function EffectSchema({ name, node, printer, cyclic }: Props): KubbReactNode { + const output = printer.print(node) + if (!output) return + + const encodedName = `${name}Encoded` + + return ( + <> + + + {output.type} + + + {cyclic && ( + + {output.encoded} + + )} + + ` : undefined}> + {output.runtime} + + + + ) +} diff --git a/packages/plugin-effect/src/generators/effectGenerator.tsx b/packages/plugin-effect/src/generators/effectGenerator.tsx new file mode 100644 index 000000000..9be3fb1d7 --- /dev/null +++ b/packages/plugin-effect/src/generators/effectGenerator.tsx @@ -0,0 +1,214 @@ +import { caseParams, collectRefNames, getSuccessResponses, isSuccessStatusCode, resolveContentTypeVariants } from '@internals/shared' +import { ast, defineGenerator } from 'kubb/kit' +import { File, jsxRenderer } from 'kubb/jsx' +import { EffectSchema } from '../components/EffectSchema.tsx' +import { printerEffect } from '../printers/printerEffect.ts' +import type { PluginEffect } from '../types.ts' + +type ResponseUnionOptions = { + responses: Array + name: string + fallbackUnknown: boolean +} + +function needsSchemaGetter(node: ast.SchemaNode): boolean { + return ast + .collect(node, { + schema(schema) { + return schema.type === 'date' && schema.representation === 'date' && schema.format === 'date' ? true : undefined + }, + }) + .some(Boolean) +} + +function needsDateTime(node: ast.SchemaNode): boolean { + return ast + .collect(node, { + schema(schema) { + return schema.type === 'date' && schema.representation === 'date' && schema.format !== 'date' ? true : undefined + }, + }) + .some(Boolean) +} + +/** + * Built-in generator for `@kubb/plugin-effect`. + */ +export const effectGenerator = defineGenerator({ + name: 'effect', + renderer: jsxRenderer, + schema(node, ctx) { + if (!node.name) return + + const { config, resolver, root } = ctx + const { output, importPath, group, regexType, printer } = ctx.options + const cyclicSchemas = new Set(ctx.meta.circularNames) + const name = resolver.name(node.name) + const file = resolver.file({ name: node.name, extname: '.ts', root, output, group: group ?? undefined }) + const schemaPrinter = printerEffect({ + resolver, + regexType, + cyclicSchemas, + currentSchemaName: name, + nodes: printer?.nodes, + }) + const imports = resolver.imports({ node, root, output, group: group ?? undefined }) + + return ( + + + {needsDateTime(node) && } + {needsSchemaGetter(node) && } + {imports.map((entry) => ( + + ))} + + + ) + }, + operation(node, ctx) { + if (!ast.isHttpOperationNode(node)) return null + + const { config, resolver, root } = ctx + const { output, importPath, group, regexType, printer } = ctx.options + const file = resolver.file({ + name: node.operationId, + extname: '.ts', + tag: node.tags[0] ?? 'default', + path: node.path, + root, + output, + group: group ?? undefined, + }) + const cyclicSchemas = new Set(ctx.meta.circularNames) + let usesSchemaGetter = false + let usesDateTime = false + let usesStruct = false + + function renderSchema({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array | null }) { + if (!schema) return null + usesSchemaGetter ||= needsSchemaGetter(schema) + usesDateTime ||= needsDateTime(schema) + usesStruct ||= !!keysToOmit?.length + const imports = resolver.imports({ node: schema, root, output, group: group ?? undefined }) + const schemaPrinter = printerEffect({ + resolver, + regexType, + keysToOmit, + cyclicSchemas, + currentSchemaName: name, + nodes: printer?.nodes, + }) + + return ( + <> + {imports.map((entry) => ( + + ))} + + + ) + } + + function renderContentVariants( + entries: Array<{ contentType: string; schema?: ast.SchemaNode | null; keysToOmit?: Array | null }>, + baseName: string, + decorate?: (schema: ast.SchemaNode) => ast.SchemaNode, + ) { + const variants = resolveContentTypeVariants(entries, baseName) + const union = ast.factory.createSchema({ + type: 'union', + members: variants.map((variant) => ast.factory.createSchema({ type: 'ref', name: variant.name })), + }) + return ( + <> + {variants.map((variant) => + renderSchema({ + schema: decorate ? decorate(variant.schema) : variant.schema, + name: variant.name, + keysToOmit: variant.keysToOmit, + }), + )} + {renderSchema({ schema: union, name: baseName })} + + ) + } + + function renderResponseUnion({ responses, name, fallbackUnknown }: ResponseUnionOptions) { + const importedNames = new Set( + responses.flatMap((response) => + (response.content ?? []).flatMap((entry) => (entry.schema ? collectRefNames(entry.schema).map((refName) => resolver.name(refName)) : [])), + ), + ) + if (importedNames.has(name)) return null + + const members = responses.map((response) => ast.factory.createSchema({ type: 'ref', name: resolver.response.status(node, response.statusCode) })) + if (fallbackUnknown && members.length === 0) return renderSchema({ schema: ast.factory.createSchema({ type: 'unknown' }), name }) + if (!members.length) return null + return renderSchema({ schema: members.length === 1 ? members[0]! : ast.factory.createSchema({ type: 'union', members }), name }) + } + + const params = caseParams(node.parameters, 'camelcase').map((param) => renderSchema({ schema: param.schema, name: resolver.param.name(node, param) })) + const responses = node.responses.map((response) => { + const variants = (response.content ?? []).filter((entry) => entry.schema) + if (variants.length > 1) return renderContentVariants(response.content!, resolver.response.status(node, response.statusCode)) + const primary = variants[0] ?? response.content?.[0] + return renderSchema({ + schema: primary?.schema ?? null, + name: resolver.response.status(node, response.statusCode), + keysToOmit: primary?.keysToOmit, + }) + }) + const responsesWithSchema = node.responses.filter((response) => response.content?.some((entry) => entry.schema)) + const successUnion = + responsesWithSchema.length > 0 + ? renderResponseUnion({ responses: getSuccessResponses(responsesWithSchema), name: resolver.response.response(node), fallbackUnknown: true }) + : null + const errorResponses = responsesWithSchema.filter((response) => !isSuccessStatusCode(response.statusCode)) + const errorUnion = + errorResponses.length > 0 ? renderResponseUnion({ responses: errorResponses, name: resolver.response.error(node), fallbackUnknown: false }) : null + const requestContent = node.requestBody?.content ?? [] + const requestBody = (() => { + if (!requestContent.length) return null + if (requestContent.length === 1) { + const entry = requestContent[0]! + if (!entry.schema) return null + return renderSchema({ + schema: { ...entry.schema, description: node.requestBody!.description ?? entry.schema.description }, + name: resolver.response.body(node), + keysToOmit: entry.keysToOmit, + }) + } + return renderContentVariants(requestContent, resolver.response.body(node), (schema) => ({ + ...schema, + description: node.requestBody!.description ?? schema.description, + })) + })() + + return ( + + + {usesDateTime && } + {usesSchemaGetter && } + {usesStruct && } + {params} + {responses} + {successUnion} + {errorUnion} + {requestBody} + + ) + }, +}) diff --git a/packages/plugin-effect/src/index.ts b/packages/plugin-effect/src/index.ts new file mode 100644 index 000000000..a6afca527 --- /dev/null +++ b/packages/plugin-effect/src/index.ts @@ -0,0 +1,7 @@ +export { EffectSchema } from './components/EffectSchema.tsx' +export { effectGenerator } from './generators/effectGenerator.tsx' +export { default, pluginEffect, pluginEffectName } from './plugin.ts' +export type { EffectSchemaCode, PrinterEffectFactory, PrinterEffectNodes, PrinterEffectOptions } from './printers/printerEffect.ts' +export { printerEffect } from './printers/printerEffect.ts' +export { resolverEffect } from './resolvers/resolverEffect.ts' +export type { PluginEffect, ResolverEffect } from './types.ts' diff --git a/packages/plugin-effect/src/plugin.ts b/packages/plugin-effect/src/plugin.ts new file mode 100644 index 000000000..2bb705d90 --- /dev/null +++ b/packages/plugin-effect/src/plugin.ts @@ -0,0 +1,66 @@ +import { createGroupConfig } from '@internals/shared' +import { definePlugin, Resolver } from 'kubb/kit' +import { effectGenerator } from './generators/effectGenerator.tsx' +import { resolverEffect } from './resolvers/resolverEffect.ts' +import type { PluginEffect } from './types.ts' + +/** + * Canonical plugin name for `@kubb/plugin-effect`. + */ +export const pluginEffectName = 'plugin-effect' satisfies PluginEffect['name'] + +/** + * Generates Effect v4 schemas and matching TypeScript types from OpenAPI. + * + * @example + * ```ts + * import { defineConfig } from 'kubb/config' + * import { pluginEffect } from '@kubb/plugin-effect' + * + * export default defineConfig({ + * input: './petStore.yaml', + * output: { path: './src/gen' }, + * plugins: [pluginEffect()], + * }) + * ``` + * + * @beta + */ +export const pluginEffect = definePlugin((options) => { + const { + output = { path: 'effect', barrel: { type: 'named' } }, + group, + exclude = [], + include, + override = [], + importPath = 'effect/Schema', + regexType = 'constructor', + printer, + resolver: userResolver, + macros: userMacros, + } = options + + return { + name: pluginEffectName, + options, + hooks: { + 'kubb:plugin:setup'(ctx) { + ctx.setOptions({ + output, + exclude, + include, + override, + group: createGroupConfig(group), + importPath, + regexType, + printer, + }) + ctx.setResolver(userResolver ? Resolver.merge(resolverEffect, userResolver) : resolverEffect) + if (userMacros?.length) ctx.setMacros(userMacros) + ctx.addGenerator(effectGenerator) + }, + }, + } +}) + +export default pluginEffect diff --git a/packages/plugin-effect/src/printers/printerEffect.test.ts b/packages/plugin-effect/src/printers/printerEffect.test.ts new file mode 100644 index 000000000..a6df807e8 --- /dev/null +++ b/packages/plugin-effect/src/printers/printerEffect.test.ts @@ -0,0 +1,134 @@ +import { ast } from 'kubb/kit' +import { describe, expect, test } from 'vitest' +import { resolverEffect } from '../resolvers/resolverEffect.ts' +import { printerEffect } from './printerEffect.ts' + +describe('printerEffect', () => { + const printer = printerEffect({ resolver: resolverEffect, regexType: 'constructor' }) + + test('prints scalar checks without validating formats', () => { + expect(printer.print(ast.factory.createSchema({ type: 'email', min: 3, format: 'email' }))).toEqual({ + runtime: 'Schema.String.check(Schema.isMinLength(3)).annotate({ format: "email" })', + type: 'string', + encoded: 'string', + }) + expect(printer.print(ast.factory.createSchema({ type: 'integer', min: 1, exclusiveMaximum: 10, multipleOf: 2 }))).toEqual({ + runtime: 'Schema.Number.check(Schema.isFinite(), Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThan(10), Schema.isMultipleOf(2))', + type: 'number', + encoded: 'number', + }) + }) + + test('keeps defaults as annotations', () => { + expect(printer.print(ast.factory.createSchema({ type: 'string', default: 'draft' }))).toEqual({ + runtime: 'Schema.String.annotate({ default: "draft" })', + type: 'string', + encoded: 'string', + }) + }) + + test('prints optional and nullable object properties', () => { + const node = ast.factory.createSchema({ + type: 'object', + properties: [ + ast.factory.createProperty({ name: 'id', required: true, schema: ast.factory.createSchema({ type: 'integer' }) }), + ast.factory.createProperty({ name: 'display-name', required: false, schema: ast.factory.createSchema({ type: 'string', nullable: true }) }), + ], + }) + expect(printer.print(node)).toEqual({ + runtime: + 'Schema.Struct({ id: Schema.Number.check(Schema.isFinite(), Schema.isInt()), "display-name": Schema.optionalKey(Schema.NullOr(Schema.String)) })', + type: '{ readonly id: number; readonly "display-name"?: string | null }', + encoded: '{ readonly id: number; readonly "display-name"?: string | null }', + }) + }) + + test('prints records and oneOf unions', () => { + const record = ast.factory.createSchema({ type: 'object', properties: [], additionalProperties: ast.factory.createSchema({ type: 'string' }) }) + expect(printer.print(record)?.runtime).toBe('Schema.StructWithRest(Schema.Struct({}), [Schema.Record(Schema.String, Schema.String)])') + + const oneOf = ast.factory.createSchema({ + type: 'union', + strategy: 'one', + members: [ast.factory.createSchema({ type: 'string' }), ast.factory.createSchema({ type: 'number' })], + }) + expect(printer.print(oneOf)?.runtime).toBe('Schema.Union([Schema.String, Schema.Number.check(Schema.isFinite())], { mode: "oneOf" })') + }) + + test('omits fields from referenced operation schemas', () => { + const schema = ast.factory.createSchema({ + type: 'object', + properties: [ + ast.factory.createProperty({ name: 'id', required: true, schema: ast.factory.createSchema({ type: 'integer' }) }), + ast.factory.createProperty({ name: 'secret', required: true, schema: ast.factory.createSchema({ type: 'string' }) }), + ], + }) + const ref = ast.factory.createSchema({ type: 'ref', name: 'Pet', ref: '#/components/schemas/Pet', schema }) + const operationPrinter = printerEffect({ resolver: resolverEffect, keysToOmit: ['secret'] }) + expect(operationPrinter.print(ref)).toEqual({ + runtime: 'Schema.Struct(Struct.omit(Pet.fields, ["secret"]))', + type: 'Omit, "secret">', + encoded: 'Omit, "secret">', + }) + }) + + test('prints date-only codecs with distinct decoded and encoded types', () => { + expect(printer.print(ast.factory.createSchema({ type: 'date', representation: 'date', format: 'date' }))).toEqual({ + runtime: + 'Schema.String.pipe(Schema.decodeTo(Schema.DateValid, { decode: SchemaGetter.transform((value) => new Date(`${value}T00:00:00.000Z`)), encode: SchemaGetter.transform((value) => value.toISOString().slice(0, 10)) })).annotate({ format: "date" })', + type: 'Date', + encoded: 'string', + }) + }) + + test('prints Effect DateTime codecs and annotations', () => { + const node = ast.factory.createSchema({ + type: 'date', + representation: 'date', + format: 'date-time', + default: '2026-07-14T10:30:00.000Z', + examples: ['2026-07-15T10:30:00.000Z'], + }) + expect(printer.print(node)).toEqual({ + runtime: + 'Schema.DateTimeUtcFromString.annotate({ format: "date-time", default: DateTime.makeUnsafe("2026-07-14T10:30:00.000Z"), examples: [DateTime.makeUnsafe("2026-07-15T10:30:00.000Z")] })', + type: 'DateTime.Utc', + encoded: 'string', + }) + }) + + test('prints recursive refs with an explicit codec contract', () => { + const cyclic = printerEffect({ resolver: resolverEffect, cyclicSchemas: new Set(['Pet']), currentSchemaName: 'Pet' }) + expect(cyclic.print(ast.factory.createSchema({ type: 'ref', name: 'Pet', ref: '#/components/schemas/Pet' }))).toEqual({ + runtime: 'Schema.suspend((): Schema.Codec => Pet)', + type: 'Pet', + encoded: 'PetEncoded', + }) + }) + + test('rejects transforming intersections that cannot be represented safely', () => { + const node = ast.factory.createSchema({ + type: 'intersection', + name: 'Timed', + members: [ast.factory.createSchema({ type: 'date', representation: 'date' }), ast.factory.createSchema({ type: 'unknown' })], + }) + expect(() => printer.print(node)).toThrowError('Effect cannot safely compose a transforming allOf schema "Timed"') + }) + + test('supports node overrides without losing encoded types', () => { + const custom = printerEffect({ + nodes: { + string(node) { + const base = this.base(node) + if (!base) return null + return { ...base, runtime: `${base.runtime}.annotate({ title: "Custom" })` } + }, + }, + }) + expect(custom.print(ast.factory.createSchema({ type: 'string' }))).toEqual({ + runtime: 'Schema.String.annotate({ title: "Custom" })', + type: 'string', + encoded: 'string', + }) + }) +}) diff --git a/packages/plugin-effect/src/printers/printerEffect.ts b/packages/plugin-effect/src/printers/printerEffect.ts new file mode 100644 index 000000000..5d16323b3 --- /dev/null +++ b/packages/plugin-effect/src/printers/printerEffect.ts @@ -0,0 +1,474 @@ +import { ast } from 'kubb/kit' +import type { PluginEffect, ResolverEffect } from '../types.ts' + +/** + * Runtime expression and the matching decoded and encoded TypeScript forms. + */ +export type EffectSchemaCode = { + /** + * Effect Schema expression emitted into the generated file. + */ + runtime: string + /** + * Type decoded by the schema. + */ + type: string + /** + * Type accepted by the schema encoder. + */ + encoded: string +} + +/** + * Partial map of Effect handlers keyed by schema node type. + * + * Each handler must keep its runtime, decoded type, and encoded type aligned. + * Use `this.base(node)` to extend the built-in result. + */ +export type PrinterEffectNodes = ast.PrinterPartial + +/** + * Options used by the Effect printer. + */ +export type PrinterEffectOptions = { + /** + * Output form for OpenAPI regular expressions. + * + * @default 'constructor' + */ + regexType?: PluginEffect['resolvedOptions']['regexType'] + /** + * Transforms raw schema names into generated identifiers. + */ + resolver?: ResolverEffect + /** + * Properties omitted from an operation schema. + */ + keysToOmit?: Array | null + /** + * Names that participate in circular references. + */ + cyclicSchemas?: ReadonlySet + /** + * Name of the component currently being printed. + */ + currentSchemaName?: string + /** + * Custom node handlers. + */ + nodes?: PrinterEffectNodes +} + +/** + * Factory contract for the Effect Schema printer. + */ +export type PrinterEffectFactory = ast.PrinterFactoryOptions<'effect', PrinterEffectOptions, EffectSchemaCode, EffectSchemaCode> + +type PrinterContext = { + transform: (node: ast.SchemaNode) => EffectSchemaCode | null + options: PrinterEffectOptions +} + +function schemaCode(runtime: string, type: string, encoded = type): EffectSchemaCode { + return { runtime, type, encoded } +} + +function propertyKey(name: string): string { + return /^[$A-Z_a-z][$\w]*$/.test(name) ? name : JSON.stringify(name) +} + +function valueLiteral(value: unknown): string { + if (typeof value === 'bigint') return `${value}n` + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'undefined' + if (typeof value === 'boolean' || value === null) return String(value) + if (Array.isArray(value)) return `[${value.map(valueLiteral).join(', ')}]` + if (typeof value === 'object' && value) { + return `{ ${Object.entries(value) + .map(([key, item]) => `${propertyKey(key)}: ${valueLiteral(item)}`) + .join(', ')} }` + } + return 'undefined' +} + +function annotationLiteral(node: ast.SchemaNode, value: unknown): string { + if (node.type === 'bigint' && (typeof value === 'number' || typeof value === 'string' || typeof value === 'bigint')) { + return `BigInt(${JSON.stringify(String(value))})` + } + if ((node.type === 'date' || node.type === 'time') && node.representation === 'date' && typeof value === 'string') { + if (node.type === 'date' && node.format !== 'date') return `DateTime.makeUnsafe(${JSON.stringify(value)})` + return `new Date(${JSON.stringify(value)})` + } + if ( + ['string', 'uuid', 'email', 'url', 'ipv4', 'ipv6', 'datetime'].includes(node.type) || + ((node.type === 'date' || node.type === 'time') && node.representation !== 'date') + ) { + return JSON.stringify(String(value)) + } + if ((node.type === 'number' || node.type === 'integer') && Number.isFinite(Number(value))) return String(Number(value)) + if (node.type === 'boolean' && (value === 'true' || value === 'false')) return value + if (node.type === 'enum') { + const values = node.namedEnumValues?.map((member) => member.value) ?? node.enumValues ?? [] + const match = values.find((member) => member === value || String(member) === String(value)) + if (match !== undefined && match !== null) return valueLiteral(match) + } + return valueLiteral(value) +} + +function literalType(value: string | number | boolean): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value) +} + +function unionType(types: Array): string { + const distinct = [...new Set(types)] + if (!distinct.length) return 'never' + if (distinct.length === 1) return distinct[0]! + return distinct.join(' | ') +} + +function intersectionType(types: Array): string { + const distinct = [...new Set(types)] + if (!distinct.length) return 'unknown' + if (distinct.length === 1) return distinct[0]! + return distinct.map((type) => (type.includes(' | ') ? `(${type})` : type)).join(' & ') +} + +function regexp(pattern: string, regexType: PrinterEffectOptions['regexType']): string { + if (regexType !== 'literal') return `new RegExp(${JSON.stringify(pattern)})` + const escaped = pattern.replaceAll('/', '\\/').replace(/\n/g, '\\n').replace(/\r/g, '\\r') + return `/${escaped}/` +} + +function checks(items: Array): string { + return items.length ? `.check(${items.join(', ')})` : '' +} + +function lengthChecks(node: { min?: number; max?: number; pattern?: string }, regexType: PrinterEffectOptions['regexType']): string { + return checks([ + ...(node.min !== undefined ? [`Schema.isMinLength(${node.min})`] : []), + ...(node.max !== undefined ? [`Schema.isMaxLength(${node.max})`] : []), + ...(node.pattern !== undefined ? [`Schema.isPattern(${regexp(node.pattern, regexType)})`] : []), + ]) +} + +function numberChecks(node: { min?: number; max?: number; exclusiveMinimum?: number; exclusiveMaximum?: number; multipleOf?: number }): Array { + return [ + ...(node.min !== undefined ? [`Schema.isGreaterThanOrEqualTo(${node.min})`] : []), + ...(node.max !== undefined ? [`Schema.isLessThanOrEqualTo(${node.max})`] : []), + ...(node.exclusiveMinimum !== undefined ? [`Schema.isGreaterThan(${node.exclusiveMinimum})`] : []), + ...(node.exclusiveMaximum !== undefined ? [`Schema.isLessThan(${node.exclusiveMaximum})`] : []), + ...(node.multipleOf !== undefined ? [`Schema.isMultipleOf(${node.multipleOf})`] : []), + ] +} + +function inferredFormat(node: ast.SchemaNode): string | undefined { + if ('format' in node && typeof node.format === 'string') return node.format + switch (node.type) { + case 'uuid': + case 'email': + case 'url': + case 'ipv4': + case 'ipv6': + return node.type + case 'datetime': + return 'date-time' + case 'time': + return 'time' + default: + return undefined + } +} + +function annotate(runtime: string, node: ast.SchemaNode): string { + const annotations: Array = [] + const format = inferredFormat(node) + if (format) annotations.push(`format: ${JSON.stringify(format)}`) + if (node.description) annotations.push(`description: ${JSON.stringify(node.description)}`) + if (node.default !== undefined) annotations.push(`default: ${annotationLiteral(node, node.default)}`) + if (node.examples?.length) annotations.push(`examples: [${node.examples.map((example) => annotationLiteral(node, example)).join(', ')}]`) + if (node.readOnly) annotations.push('readOnly: true') + if (node.writeOnly) annotations.push('writeOnly: true') + return annotations.length ? `${runtime}.annotate({ ${annotations.join(', ')} })` : runtime +} + +function applyModifiers(code: EffectSchemaCode, node: ast.SchemaNode, includeOptional = true): EffectSchemaCode { + const nullable = !!node.nullable + const optional = includeOptional && !!node.optional + const nullish = !!node.nullish || (nullable && optional) + const runtime = (() => { + if (nullish) return `Schema.NullishOr(${code.runtime})` + if (nullable) return `Schema.NullOr(${code.runtime})` + if (optional) return `Schema.UndefinedOr(${code.runtime})` + return code.runtime + })() + const type = unionType([code.type, ...(nullable || nullish ? ['null'] : []), ...(optional || nullish ? ['undefined'] : [])]) + const encoded = unionType([code.encoded, ...(nullable || nullish ? ['null'] : []), ...(optional || nullish ? ['undefined'] : [])]) + return { runtime, type, encoded } +} + +function finish(code: EffectSchemaCode, node: ast.SchemaNode, includeOptional = true): EffectSchemaCode { + return applyModifiers({ ...code, runtime: annotate(code.runtime, node) }, node, includeOptional) +} + +function buildObject(ctx: PrinterContext, node: ast.ObjectSchemaNode, keysToOmit: ReadonlySet = new Set()): EffectSchemaCode { + const fields: Array = [] + const typeFields: Array = [] + const encodedFields: Array = [] + + for (const property of node.properties ?? []) { + if (keysToOmit.has(property.name)) continue + const base = ctx.transform(property.schema) ?? schemaCode('Schema.Unknown', 'unknown') + const meta = ast.syncSchemaRef(property.schema) + const optional = property.required === false || !!property.schema.optional || !!meta.optional || !!property.schema.nullish || !!meta.nullish + const propertyNode: ast.SchemaNode = { ...meta, optional: false, nullish: false } + const value = finish(base, propertyNode, false) + const runtime = optional ? `Schema.optionalKey(${value.runtime})` : value.runtime + const key = propertyKey(property.name) + fields.push(`${key}: ${runtime}`) + typeFields.push(`readonly ${key}${optional ? '?' : ''}: ${value.type}`) + encodedFields.push(`readonly ${key}${optional ? '?' : ''}: ${value.encoded}`) + } + + const struct = `Schema.Struct({${fields.length ? ` ${fields.join(', ')} ` : ''}})` + const type = `{${typeFields.length ? ` ${typeFields.join('; ')} ` : ''}}` + const encoded = `{${encodedFields.length ? ` ${encodedFields.join('; ')} ` : ''}}` + const records: Array = [] + + for (const [pattern, valueNode] of Object.entries(node.patternProperties ?? {})) { + const value = finish(ctx.transform(valueNode) ?? schemaCode('Schema.Unknown', 'unknown'), valueNode) + records.push({ + runtime: `Schema.Record(Schema.String.check(Schema.isPattern(${regexp(pattern, ctx.options.regexType)})), ${value.runtime})`, + type: `Readonly>`, + encoded: `Readonly>`, + }) + } + + if (node.additionalProperties === true) { + records.push(schemaCode('Schema.Record(Schema.String, Schema.Json)', 'Readonly>')) + } else if (node.additionalProperties) { + const value = finish(ctx.transform(node.additionalProperties) ?? schemaCode('Schema.Unknown', 'unknown'), node.additionalProperties) + records.push({ + runtime: `Schema.Record(Schema.String, ${value.runtime})`, + type: `Readonly>`, + encoded: `Readonly>`, + }) + } + + if (!records.length) return schemaCode(struct, type, encoded) + return { + runtime: `Schema.StructWithRest(${struct}, [${records.map((record) => record.runtime).join(', ')}])`, + type: intersectionType([type, ...records.map((record) => record.type)]), + encoded: intersectionType([encoded, ...records.map((record) => record.encoded)]), + } +} + +function omitRef(code: EffectSchemaCode, node: ast.RefSchemaNode, keys: Array): EffectSchemaCode { + const resolved = ast.syncSchemaRef(node) + const source = code.runtime.startsWith('Schema.suspend(') ? code.type : code.runtime + const hasRest = resolved.type === 'object' && !!(resolved.additionalProperties || resolved.patternProperties) + const fields = hasRest ? `${source}.schema.fields` : `${source}.fields` + const struct = `Schema.Struct(Struct.omit(${fields}, ${valueLiteral(keys)}))` + const runtime = hasRest ? `Schema.StructWithRest(${struct}, ${source}.records)` : struct + const keyType = unionType(keys.map((key) => JSON.stringify(key))) + return { + runtime, + type: `Omit, ${keyType}>`, + encoded: `Omit, ${keyType}>`, + } +} + +function omitRoot(ctx: PrinterContext, node: ast.SchemaNode, code: EffectSchemaCode, keys: Array): EffectSchemaCode { + if (node.type === 'object') return buildObject(ctx, node, new Set(keys)) + if (node.type === 'ref') return omitRef(code, node, keys) + if (node.type === 'union') { + const members = (node.members ?? []).map((member) => { + const transformed = ctx.transform(member) ?? schemaCode('Schema.Unknown', 'unknown') + return finish(omitRoot(ctx, member, transformed, keys), member) + }) + return { + runtime: `Schema.Union([${members.map((member) => member.runtime).join(', ')}]${node.strategy === 'one' ? ', { mode: "oneOf" }' : ''})`, + type: unionType(members.map((member) => member.type)), + encoded: unionType(members.map((member) => member.encoded)), + } + } + return code +} + +function typeFromPath(path: string | undefined): string { + if (!path) return 'string' + const value = path.replaceAll('`', '\\`').replace(/\{[^}]+\}/g, '${string}') + return `\`${value}\`` +} + +function containsTransformation(node: ast.SchemaNode, seen: Set = new Set()): boolean { + if ((node.type === 'date' || node.type === 'time') && node.representation === 'date') return true + if (node.type === 'ref') { + const name = ast.resolveRefName(node) + if (name) { + if (seen.has(name)) return false + seen.add(name) + } + const resolved = ast.syncSchemaRef(node) + return resolved.type !== 'ref' && containsTransformation(resolved, seen) + } + if ('properties' in node && node.properties?.some((property) => containsTransformation(property.schema, seen))) return true + if ('items' in node && node.items?.some((item) => containsTransformation(item, seen))) return true + if ('members' in node && node.members?.some((member) => containsTransformation(member, seen))) return true + if ('additionalProperties' in node && node.additionalProperties && node.additionalProperties !== true) { + return containsTransformation(node.additionalProperties, seen) + } + return false +} + +function isObjectNode(node: ast.SchemaNode): node is ast.ObjectSchemaNode { + return node.type === 'object' +} + +/** + * Prints Kubb schema nodes as Effect v4 Schema expressions and matching types. + */ +export const printerEffect = ast.createPrinter((options) => ({ + name: 'effect', + options, + nodes: { + any: () => schemaCode('Schema.Any', 'any'), + unknown: () => schemaCode('Schema.Unknown', 'unknown'), + void: () => schemaCode('Schema.Void', 'void'), + never: () => schemaCode('Schema.Never', 'never'), + boolean: () => schemaCode('Schema.Boolean', 'boolean'), + null: () => schemaCode('Schema.Null', 'null'), + string(node) { + return schemaCode(`Schema.String${lengthChecks(node, this.options.regexType)}`, 'string') + }, + number(node) { + return schemaCode(`Schema.Number${checks(['Schema.isFinite()', ...numberChecks(node)])}`, 'number') + }, + integer(node) { + return schemaCode(`Schema.Number${checks(['Schema.isFinite()', 'Schema.isInt()', ...numberChecks(node)])}`, 'number') + }, + bigint: () => schemaCode('Schema.BigInt', 'bigint'), + date(node) { + if (node.representation !== 'date') return schemaCode('Schema.String', 'string') + if (node.format === 'date') { + return schemaCode( + 'Schema.String.pipe(Schema.decodeTo(Schema.DateValid, { decode: SchemaGetter.transform((value) => new Date(`${value}T00:00:00.000Z`)), encode: SchemaGetter.transform((value) => value.toISOString().slice(0, 10)) }))', + 'Date', + 'string', + ) + } + return schemaCode('Schema.DateTimeUtcFromString', 'DateTime.Utc', 'string') + }, + datetime: () => schemaCode('Schema.String', 'string'), + time(node) { + return node.representation === 'date' + ? schemaCode('Schema.DateFromString.check(Schema.isDateValid())', 'Date', 'string') + : schemaCode('Schema.String', 'string') + }, + uuid(node) { + return schemaCode(`Schema.String${lengthChecks(node, this.options.regexType)}`, 'string') + }, + email(node) { + return schemaCode(`Schema.String${lengthChecks(node, this.options.regexType)}`, 'string') + }, + url(node) { + return schemaCode(`Schema.String${lengthChecks(node, this.options.regexType)}`, typeFromPath(node.path)) + }, + ipv4: () => schemaCode('Schema.String', 'string'), + ipv6: () => schemaCode('Schema.String', 'string'), + blob: () => schemaCode('Schema.instanceOf(Blob)', 'Blob'), + enum(node) { + const values = (node.namedEnumValues?.map((value) => value.value) ?? node.enumValues ?? []).filter( + (value): value is string | number | boolean => value !== null && value !== undefined, + ) + if (!values.length) return schemaCode('Schema.Never', 'never') + if (values.length === 1) return schemaCode(`Schema.Literal(${valueLiteral(values[0])})`, literalType(values[0]!)) + return schemaCode(`Schema.Literals(${valueLiteral(values)})`, unionType(values.map(literalType))) + }, + ref(node) { + const refName = ast.resolveRefName(node) + if (!refName) return null + const name = node.ref ? (this.options.resolver?.name(refName) ?? refName) : node.name! + const isCyclic = !!node.ref && !!this.options.cyclicSchemas?.has(refName) + const encoded = isCyclic && name === this.options.currentSchemaName ? `${name}Encoded` : `typeof ${name}.Encoded` + const runtime = isCyclic ? `Schema.suspend((): Schema.Codec<${name}, ${encoded}> => ${name})` : name + return schemaCode(runtime, name, encoded) + }, + object(node) { + return buildObject(this, node) + }, + array(node) { + const items = (node.items ?? []).map((item) => finish(this.transform(item) ?? schemaCode('Schema.Unknown', 'unknown'), item)) + const item = + items.length === 0 + ? schemaCode('Schema.Unknown', 'unknown') + : items.length === 1 + ? items[0]! + : schemaCode( + `Schema.Union([${items.map((entry) => entry.runtime).join(', ')}])`, + unionType(items.map((entry) => entry.type)), + unionType(items.map((entry) => entry.encoded)), + ) + const runtime = `Schema.Array(${item.runtime})${lengthChecks(node, this.options.regexType)}${node.unique ? '.check(Schema.isUnique())' : ''}` + return schemaCode(runtime, `ReadonlyArray<${item.type}>`, `ReadonlyArray<${item.encoded}>`) + }, + tuple(node) { + const items = (node.items ?? []).map((item) => finish(this.transform(item) ?? schemaCode('Schema.Unknown', 'unknown'), item)) + return schemaCode( + `Schema.Tuple([${items.map((item) => item.runtime).join(', ')}])`, + `readonly [${items.map((item) => item.type).join(', ')}]`, + `readonly [${items.map((item) => item.encoded).join(', ')}]`, + ) + }, + union(node) { + const members = (node.members ?? []).map((member) => finish(this.transform(member) ?? schemaCode('Schema.Unknown', 'unknown'), member)) + if (!members.length) return schemaCode('Schema.Never', 'never') + if (members.length === 1) return members[0]! + return { + runtime: `Schema.Union([${members.map((member) => member.runtime).join(', ')}]${node.strategy === 'one' ? ', { mode: "oneOf" }' : ''})`, + type: unionType(members.map((member) => member.type)), + encoded: unionType(members.map((member) => member.encoded)), + } + }, + intersection(node) { + const members = node.members ?? [] + const outputs = members.map((member) => finish(this.transform(member) ?? schemaCode('Schema.Unknown', 'unknown'), member)) + if (!outputs.length) return schemaCode('Schema.Unknown', 'unknown') + if (outputs.length === 1) return outputs[0]! + + const objectMembers = members.map((member) => ast.syncSchemaRef(member)) + if (objectMembers.every(isObjectNode)) { + const fieldSources = outputs.map((output, index) => { + const member = objectMembers[index]! + return member.additionalProperties || member.patternProperties ? `${output.runtime}.schema.fields` : `${output.runtime}.fields` + }) + const struct = `Schema.Struct({ ${fieldSources.map((source) => `...${source}`).join(', ')} })` + const recordSources = outputs.flatMap((output, index) => { + const member = objectMembers[index]! + return member.additionalProperties || member.patternProperties ? [`...${output.runtime}.records`] : [] + }) + return { + runtime: recordSources.length ? `Schema.StructWithRest(${struct}, [${recordSources.join(', ')}])` : struct, + type: intersectionType(outputs.map((output) => output.type)), + encoded: intersectionType(outputs.map((output) => output.encoded)), + } + } + + if (members.some((member) => containsTransformation(member))) { + throw new Error(`Effect cannot safely compose a transforming allOf schema${node.name ? ` "${node.name}"` : ''}. Add a printer intersection override.`) + } + const type = intersectionType(outputs.map((output) => output.type)) + return schemaCode( + `Schema.declare<${type}>((value): value is ${type} => [${outputs.map((output) => output.runtime).join(', ')}].every((schema) => Schema.is(schema)(value)))`, + type, + ) + }, + }, + overrides: options.nodes, + print(node) { + const transformed = this.transform(node) + if (!transformed) return null + const meta = ast.syncSchemaRef(node) + const omitted = this.options.keysToOmit?.length ? omitRoot(this, node, transformed, this.options.keysToOmit) : transformed + return finish(omitted, meta) + }, +})) diff --git a/packages/plugin-effect/src/resolvers/resolverEffect.test.ts b/packages/plugin-effect/src/resolvers/resolverEffect.test.ts new file mode 100644 index 000000000..ed10217c8 --- /dev/null +++ b/packages/plugin-effect/src/resolvers/resolverEffect.test.ts @@ -0,0 +1,16 @@ +import { ast } from 'kubb/kit' +import { describe, expect, test } from 'vitest' +import { resolverEffect } from './resolverEffect.ts' + +describe('resolverEffect', () => { + test('uses PascalCase for schemas and files', () => { + expect(resolverEffect.name('pet status')).toBe('PetStatus') + expect(resolverEffect.file({ name: 'pet status', extname: '.ts', root: '.', output: { path: 'effect' } }).baseName).toBe('PetStatus.ts') + }) + + test('uses operation response conventions', () => { + const operation = ast.factory.createOperation({ operationId: 'listPets', method: 'GET', path: '/pets', responses: [] }) + expect(resolverEffect.response.status(operation, '200')).toBe('ListPetsStatus200') + expect(resolverEffect.response.error(operation)).toBe('ListPetsError') + }) +}) diff --git a/packages/plugin-effect/src/resolvers/resolverEffect.ts b/packages/plugin-effect/src/resolvers/resolverEffect.ts new file mode 100644 index 000000000..da0f190d3 --- /dev/null +++ b/packages/plugin-effect/src/resolvers/resolverEffect.ts @@ -0,0 +1,22 @@ +import { createCasedFile, createOperationParamResolver, createOperationResponseResolver } from '@internals/shared' +import { ensureValidVarName, pascalCase } from '@internals/utils' +import { createResolver } from 'kubb/kit' +import type { PluginEffect } from '../types.ts' + +/** + * Default PascalCase resolver for Effect schemas and their matching types. + */ +export const resolverEffect = createResolver({ + pluginName: 'plugin-effect', + name(name) { + return ensureValidVarName(pascalCase(name)) + }, + file: createCasedFile(pascalCase), + param: createOperationParamResolver(), + response: { + ...createOperationResponseResolver(), + error(node) { + return this.name(`${node.operationId} Error`) + }, + }, +}) diff --git a/packages/plugin-effect/src/types.ts b/packages/plugin-effect/src/types.ts new file mode 100644 index 000000000..3cd9ef364 --- /dev/null +++ b/packages/plugin-effect/src/types.ts @@ -0,0 +1,128 @@ +import type { ast, Exclude, Group, Include, Output, OutputOptions, Override, PluginFactoryOptions, Resolver, ResolverPatch } from 'kubb/kit' +import type { PrinterEffectNodes } from './printers/printerEffect.ts' + +/** + * Resolver for Effect schema, parameter, and response names. + */ +export type ResolverEffect = Resolver & { + /** + * Names for operation parameters. + */ + param: { + /** + * Resolves an individual parameter name. + */ + name(node: ast.OperationNode, param: ast.ParameterNode): string + /** + * Resolves a path parameter name. + */ + path(node: ast.OperationNode, param: ast.ParameterNode): string + /** + * Resolves a query parameter name. + */ + query(node: ast.OperationNode, param: ast.ParameterNode): string + /** + * Resolves a header parameter name. + */ + headers(node: ast.OperationNode, param: ast.ParameterNode): string + } + /** + * Names for operation responses and request bodies. + */ + response: { + /** + * Resolves a response name for a status code. + */ + status(node: ast.OperationNode, statusCode: ast.StatusCode): string + /** + * Resolves a request body name. + */ + body(node: ast.OperationNode): string + /** + * Resolves the response collection name. + */ + responses(node: ast.OperationNode): string + /** + * Resolves the successful response union name. + */ + response(node: ast.OperationNode): string + /** + * Resolves the error response union name. + */ + error(node: ast.OperationNode): string + } +} + +/** + * Options for generating Effect v4 schemas. + */ +export type Options = OutputOptions & { + /** + * Skips operations matching at least one entry. + */ + exclude?: Array + /** + * Restricts generation to matching operations. + */ + include?: Array + /** + * Applies different options to matching operations. + */ + override?: Array> + /** + * Module specifier for the Effect Schema namespace import. + * + * @default 'effect/Schema' + */ + importPath?: 'effect/Schema' | (string & {}) + /** + * Output form for OpenAPI regular expressions. + * + * @default 'constructor' + */ + regexType?: 'literal' | 'constructor' + /** + * Overrides generated schema and operation names. + */ + resolver?: ResolverPatch + /** + * Replaces handlers for individual schema node types. + */ + printer?: { + /** + * Custom node handlers. + */ + nodes?: PrinterEffectNodes + } + /** + * Macros applied before printing each node. + */ + macros?: Array +} + +/** + * Fully resolved options supplied to the Effect generator. + */ +export type ResolvedOptions = { + output: Output + exclude: Array + include: Array | undefined + override: Array> + group: Group | null + importPath: NonNullable + regexType: NonNullable + printer: Options['printer'] +} + +/** + * Kubb registry entry for `@kubb/plugin-effect`. + */ +export type PluginEffect = PluginFactoryOptions<'plugin-effect', Options, ResolvedOptions, ResolverEffect> + +declare global { + namespace Kubb { + interface PluginRegistry { + 'plugin-effect': PluginEffect + } + } +} diff --git a/packages/plugin-effect/tsconfig.json b/packages/plugin-effect/tsconfig.json new file mode 100644 index 000000000..f5c1b7f74 --- /dev/null +++ b/packages/plugin-effect/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "kubb/jsx", + "types": ["bun-types", "../../reset.d.ts"] + }, + "include": ["src/**/*", "./package.json", "./tsdown.config.ts", "./vitest.config.ts"] +} diff --git a/packages/plugin-effect/tsdown.config.ts b/packages/plugin-effect/tsdown.config.ts new file mode 100644 index 000000000..7ae389f16 --- /dev/null +++ b/packages/plugin-effect/tsdown.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, type UserConfig } from 'tsdown' + +const entry = { + index: 'src/index.ts', +} + +const shared: Partial = { + platform: 'node', + sourcemap: true, + shims: true, + exports: true, + deps: { + neverBundle: [/^@kubb\//], + alwaysBundle: [/@internals/], + }, + fixedExtension: false, + outputOptions: { + keepNames: true, + }, +} + +export default defineConfig([ + { + entry, + format: 'esm', + dts: true, + ...shared, + }, + { + entry, + format: 'cjs', + dts: false, + ...shared, + }, +]) diff --git a/packages/plugin-effect/vitest.config.ts b/packages/plugin-effect/vitest.config.ts new file mode 100644 index 000000000..c84a98928 --- /dev/null +++ b/packages/plugin-effect/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + dir: './src', + }, + resolve: { + tsconfigPaths: true, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a987bb7..457bd1d4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ catalogs: '@vitest/ui': specifier: ^4.1.10 version: 4.1.10 + effect: + specifier: 4.0.0-beta.98 + version: 4.0.0-beta.98 kubb: specifier: 5.0.0-beta.97 version: 5.0.0-beta.97 @@ -252,6 +255,24 @@ importers: specifier: 'catalog:' version: 6.0.3 + examples/effect: + dependencies: + '@kubb/adapter-oas': + specifier: 'catalog:' + version: 5.0.0-beta.97(openapi-types@12.1.3) + '@kubb/plugin-effect': + specifier: workspace:* + version: link:../../packages/plugin-effect + effect: + specifier: 'catalog:' + version: 4.0.0-beta.98 + kubb: + specifier: 'catalog:' + version: 5.0.0-beta.97(openapi-types@12.1.3)(rolldown@1.1.5)(typescript@6.0.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + typescript: + specifier: 'catalog:' + version: 6.0.3 + examples/faker: dependencies: '@faker-js/faker': @@ -674,6 +695,21 @@ importers: specifier: 'catalog:' version: 5.0.0-beta.97(openapi-types@12.1.3)(rolldown@1.1.5)(typescript@6.0.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/plugin-effect: + devDependencies: + '@internals/shared': + specifier: workspace:* + version: link:../../internals/shared + '@internals/utils': + specifier: workspace:* + version: link:../../internals/utils + effect: + specifier: 'catalog:' + version: 4.0.0-beta.98 + kubb: + specifier: 'catalog:' + version: 5.0.0-beta.97(openapi-types@12.1.3)(rolldown@1.1.5)(typescript@6.0.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/plugin-faker: dependencies: '@kubb/plugin-ts': @@ -1296,6 +1332,36 @@ packages: '@cfworker/json-schema': optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@mswjs/http-middleware@0.10.3': resolution: {integrity: sha512-6CoX9IivDF7hggORdA4vX6uz+pkY1urGQMhmviHmYya/0b4EXwmhaXlGLQG3G29heqb3qdjp61V0+E2xRtyR5A==} engines: {node: '>=18'} @@ -1336,9 +1402,6 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-project/types@0.137.0': - resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} - '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -1608,73 +1671,36 @@ packages: resolution: {integrity: sha512-hrG//9/+RpOZTrUDirU4OzfMqaxCdY5BMpr3YuLf3Rje9rfNsydQJsH9iCPTie9ZRu9xe+0OUD4fHe0JmGwa2w==} engines: {node: '>=20'} - '@rolldown/binding-android-arm64@1.1.3': - resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.3': - resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - '@rolldown/binding-darwin-arm64@1.1.5': resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.3': - resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.3': - resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - '@rolldown/binding-freebsd-x64@1.1.5': resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.3': - resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-arm64-gnu@1.1.5': resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1682,13 +1708,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.3': - resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rolldown/binding-linux-arm64-musl@1.1.5': resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1696,13 +1715,6 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1710,13 +1722,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.3': - resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1724,13 +1729,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.3': - resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1738,13 +1736,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.3': - resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - '@rolldown/binding-linux-x64-musl@1.1.5': resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1752,46 +1743,23 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.3': - resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.1.5': resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.3': - resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.1.5': resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.1.3': - resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.1.5': resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.3': - resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2552,6 +2520,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-beta.98: + resolution: {integrity: sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2683,6 +2654,10 @@ packages: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} engines: {'0': node >=0.6.0} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2729,6 +2704,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2920,6 +2898,10 @@ packages: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -3064,6 +3046,9 @@ packages: engines: {node: '>=22'} hasBin: true + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -3260,6 +3245,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.4: + resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + msw@2.15.0: resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} engines: {node: '>=18'} @@ -3270,6 +3262,9 @@ packages: typescript: optional: true + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3299,6 +3294,10 @@ packages: encoding: optional: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -3497,6 +3496,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -3592,11 +3594,6 @@ packages: vue-tsc: optional: true - rolldown@1.1.3: - resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3853,6 +3850,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@4.1.2: + resolution: {integrity: sha512-m0vXfHODcw3gk+KONAOlVQ5yNHc3yS3B1ybM3HS1vqDoS0RWTDDVBVVTYi8hH0k+2OM1vmo9fb1WX9EVqjqfHA==} + engines: {node: '>=20'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -4053,6 +4054,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + valibot@1.4.2: resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: @@ -4689,6 +4694,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@mswjs/http-middleware@0.10.3(msw@2.15.0(@types/node@26.0.1)(typescript@6.0.3))': dependencies: express: 4.22.2 @@ -4736,8 +4759,6 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxc-project/types@0.137.0': {} - '@oxc-project/types@0.139.0': {} '@oxfmt/binding-android-arm-eabi@0.58.0': @@ -4884,85 +4905,42 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@rolldown/binding-android-arm64@1.1.3': - optional: true - '@rolldown/binding-android-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-arm64@1.1.3': - optional: true - '@rolldown/binding-darwin-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-x64@1.1.3': - optional: true - '@rolldown/binding-darwin-x64@1.1.5': optional: true - '@rolldown/binding-freebsd-x64@1.1.3': - optional: true - '@rolldown/binding-freebsd-x64@1.1.5': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.3': - optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.3': - optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.3': - optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.3': - optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-musl@1.1.3': - optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': optional: true - '@rolldown/binding-openharmony-arm64@1.1.3': - optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': optional: true - '@rolldown/binding-wasm32-wasi@1.1.3': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 @@ -4970,15 +4948,9 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.3': - optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.3': - optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true @@ -5707,6 +5679,19 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.0-beta.98: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.4 + multipasta: 0.2.8 + toml: 4.1.2 + uuid: 14.0.1 + yaml: 2.9.0 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -5873,6 +5858,10 @@ snapshots: extsprintf@1.3.0: {} + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -5932,6 +5921,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way-ts@0.1.6: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6113,6 +6104,8 @@ snapshots: ini@2.0.0: {} + ini@7.0.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -6281,6 +6274,8 @@ snapshots: - vite - webpack + kubernetes-types@1.30.0: {} + leven@3.1.0: {} lightningcss-android-arm64@1.32.0: @@ -6429,6 +6424,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.4: + optionalDependencies: + msgpackr-extract: 3.0.4 + msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3): dependencies: '@inquirer/confirm': 6.1.1(@types/node@22.20.1) @@ -6480,6 +6491,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + multipasta@0.2.8: {} + mute-stream@3.0.0: {} nanoid@3.3.15: {} @@ -6494,6 +6507,11 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -6669,6 +6687,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pure-rand@8.4.2: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -6748,27 +6768,6 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.1.3: - dependencies: - '@oxc-project/types': 0.137.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.3 - '@rolldown/binding-darwin-arm64': 1.1.3 - '@rolldown/binding-darwin-x64': 1.1.3 - '@rolldown/binding-freebsd-x64': 1.1.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 - '@rolldown/binding-linux-arm64-gnu': 1.1.3 - '@rolldown/binding-linux-arm64-musl': 1.1.3 - '@rolldown/binding-linux-ppc64-gnu': 1.1.3 - '@rolldown/binding-linux-s390x-gnu': 1.1.3 - '@rolldown/binding-linux-x64-gnu': 1.1.3 - '@rolldown/binding-linux-x64-musl': 1.1.3 - '@rolldown/binding-openharmony-arm64': 1.1.3 - '@rolldown/binding-wasm32-wasi': 1.1.3 - '@rolldown/binding-win32-arm64-msvc': 1.1.3 - '@rolldown/binding-win32-x64-msvc': 1.1.3 - rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -7099,6 +7098,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.1.2: {} + totalist@3.0.1: {} tough-cookie@5.1.2: @@ -7237,7 +7238,7 @@ snapshots: unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.4 + picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 optionalDependencies: rolldown: 1.1.5 @@ -7246,7 +7247,7 @@ snapshots: unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.4 + picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 optionalDependencies: rolldown: 1.1.5 @@ -7264,6 +7265,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@14.0.1: {} + valibot@1.4.2(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -7279,9 +7282,9 @@ snapshots: vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.4 + picomatch: 4.0.5 postcss: 8.5.15 - rolldown: 1.1.3 + rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 @@ -7292,9 +7295,9 @@ snapshots: vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.4 + picomatch: 4.0.5 postcss: 8.5.15 - rolldown: 1.1.3 + rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 507cc74ed..242810574 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ allowBuilds: es5-ext: true esbuild: true msw: true + msgpackr-extract: false vue-demi: true catalog: @@ -21,6 +22,7 @@ catalog: '@types/react': ^19.2.17 '@vitest/coverage-v8': ^4.1.10 '@vitest/ui': ^4.1.10 + effect: 4.0.0-beta.98 kubb: 5.0.0-beta.97 oxfmt: ^0.58.0 oxlint: ^1.73.0 @@ -52,5 +54,6 @@ minimumReleaseAgeExclude: - 'unplugin-kubb' - '@types/*' - '@clack/core' + - 'effect@4.0.0-beta.98' pmOnFail: warn resolutionMode: highest diff --git a/tsconfig.json b/tsconfig.json index 4cb88ca9e..f32b84868 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "@kubb/plugin-faker": ["./packages/plugin-faker/src/index.ts"], "@kubb/plugin-cypress": ["./packages/plugin-cypress/src/index.ts"], "@kubb/plugin-zod/utils": ["./packages/plugin-zod/src/utils/index.ts"], - "@kubb/plugin-zod": ["./packages/plugin-zod/src/index.ts"] + "@kubb/plugin-zod": ["./packages/plugin-zod/src/index.ts"], + "@kubb/plugin-effect": ["./packages/plugin-effect/src/index.ts"] } }, "include": ["./package.json", "./tsdown.config.ts", "./vitest.config.ts", "./src/**/*", "tests"], From 6980b64cc97bdd43f4c3c3b58f083d8e477c56fc Mon Sep 17 00:00:00 2001 From: "NMNM.CC" Date: Tue, 14 Jul 2026 20:42:17 +0800 Subject: [PATCH 2/4] feat(plugin-effect-httpapiclient): add Effect HttpApiClient generation --- .changeset/quiet-clients-effect.md | 5 + .github/labeler.yml | 3 + README.md | 1 + examples/effect-httpapiclient/kubb.config.js | 21 + examples/effect-httpapiclient/package.json | 33 + examples/effect-httpapiclient/petStore.yaml | 159 +++++ .../effect-httpapiclient/src/client.test.ts | 114 ++++ .../src/gen/effect/ApiError.ts | 10 + .../src/gen/effect/GetPet.ts | 40 ++ .../src/gen/effect/ListPets.ts | 19 + .../src/gen/effect/Order.ts | 14 + .../src/gen/effect/OrderInput.ts | 10 + .../src/gen/effect/Pet.ts | 15 + .../src/gen/effect/PlaceOrder.ts | 19 + .../src/gen/effect/index.ts | 16 + .../src/gen/effectHttpApiClient/api.ts | 27 + .../src/gen/effectHttpApiClient/apiClient.ts | 17 + .../effectHttpApiClient/cookieParameters.ts | 90 +++ .../gen/effectHttpApiClient/getPetEndpoint.ts | 41 ++ .../src/gen/effectHttpApiClient/index.ts | 11 + .../effectHttpApiClient/listPetsEndpoint.ts | 22 + .../parameterSerialization.ts | 154 +++++ .../effectHttpApiClient/placeOrderEndpoint.ts | 15 + .../src/gen/effectHttpApiClient/security.ts | 235 +++++++ .../effect-httpapiclient/src/gen/index.ts | 16 + examples/effect-httpapiclient/src/index.ts | 1 + examples/effect-httpapiclient/tsconfig.json | 7 + internals/shared/src/index.ts | 1 + .../plugin-effect-httpapiclient/CHANGELOG.md | 1 + .../plugin-effect-httpapiclient/README.md | 80 +++ .../plugin-effect-httpapiclient/package.json | 71 ++ .../src/cookieRuntime.ts | 85 +++ .../httpApiClientGenerator.test.tsx | 144 ++++ .../src/generators/httpApiClientGenerator.tsx | 627 ++++++++++++++++++ .../plugin-effect-httpapiclient/src/index.ts | 4 + .../src/parameterRuntime.ts | 156 +++++ .../plugin-effect-httpapiclient/src/plugin.ts | 67 ++ .../resolverEffectHttpApiClient.test.ts | 16 + .../resolvers/resolverEffectHttpApiClient.ts | 41 ++ .../src/security.test.ts | 59 ++ .../src/security.ts | 176 +++++ .../src/securityRuntime.ts | 269 ++++++++ .../plugin-effect-httpapiclient/src/types.ts | 134 ++++ .../plugin-effect-httpapiclient/tsconfig.json | 9 + .../tsdown.config.ts | 35 + .../vitest.config.ts | 10 + pnpm-lock.yaml | 40 ++ tsconfig.json | 3 +- 48 files changed, 3142 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-clients-effect.md create mode 100644 examples/effect-httpapiclient/kubb.config.js create mode 100644 examples/effect-httpapiclient/package.json create mode 100644 examples/effect-httpapiclient/petStore.yaml create mode 100644 examples/effect-httpapiclient/src/client.test.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/ApiError.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/GetPet.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/ListPets.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/Order.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/OrderInput.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/Pet.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/PlaceOrder.ts create mode 100644 examples/effect-httpapiclient/src/gen/effect/index.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/api.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/apiClient.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/cookieParameters.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/getPetEndpoint.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/index.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/listPetsEndpoint.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/parameterSerialization.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/placeOrderEndpoint.ts create mode 100644 examples/effect-httpapiclient/src/gen/effectHttpApiClient/security.ts create mode 100644 examples/effect-httpapiclient/src/gen/index.ts create mode 100644 examples/effect-httpapiclient/src/index.ts create mode 100644 examples/effect-httpapiclient/tsconfig.json create mode 100644 packages/plugin-effect-httpapiclient/CHANGELOG.md create mode 100644 packages/plugin-effect-httpapiclient/README.md create mode 100644 packages/plugin-effect-httpapiclient/package.json create mode 100644 packages/plugin-effect-httpapiclient/src/cookieRuntime.ts create mode 100644 packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx create mode 100644 packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx create mode 100644 packages/plugin-effect-httpapiclient/src/index.ts create mode 100644 packages/plugin-effect-httpapiclient/src/parameterRuntime.ts create mode 100644 packages/plugin-effect-httpapiclient/src/plugin.ts create mode 100644 packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.test.ts create mode 100644 packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.ts create mode 100644 packages/plugin-effect-httpapiclient/src/security.test.ts create mode 100644 packages/plugin-effect-httpapiclient/src/security.ts create mode 100644 packages/plugin-effect-httpapiclient/src/securityRuntime.ts create mode 100644 packages/plugin-effect-httpapiclient/src/types.ts create mode 100644 packages/plugin-effect-httpapiclient/tsconfig.json create mode 100644 packages/plugin-effect-httpapiclient/tsdown.config.ts create mode 100644 packages/plugin-effect-httpapiclient/vitest.config.ts diff --git a/.changeset/quiet-clients-effect.md b/.changeset/quiet-clients-effect.md new file mode 100644 index 000000000..1ff3ed28a --- /dev/null +++ b/.changeset/quiet-clients-effect.md @@ -0,0 +1,5 @@ +--- +"@kubb/plugin-effect-httpapiclient": minor +--- + +Add Effect v4 `HttpApi` contract and `HttpApiClient` generation. The generated client preserves status and content-type schemas, decodes date-time values as `DateTime.Utc`, and provides typed static or dynamic security credentials. diff --git a/.github/labeler.yml b/.github/labeler.yml index ab9f05e1f..6337b6ec7 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -52,6 +52,9 @@ '@kubb/plugin-effect': - any: ['packages/plugin-effect/**', '!packages/plugin-effect/package.json'] +'@kubb/plugin-effect-httpapiclient': + - any: ['packages/plugin-effect-httpapiclient/**', '!packages/plugin-effect-httpapiclient/package.json'] + 'unplugin-kubb': - any: ['packages/unplugin-kubb/**', '!packages/unplugin-kubb/package.json'] diff --git a/README.md b/README.md index cca3b2752..cb6b251fc 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Maintained by the Kubb team. Kubb v5 OpenAPI configs use [`@kubb/adapter-oas`](h | Package | Version | Description | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | | [`@kubb/plugin-axios`](./packages/plugin-axios) | [![npm version](https://img.shields.io/npm/v/@kubb/plugin-axios.svg)](https://npmx.dev/package/@kubb/plugin-axios) | Type-safe HTTP client based on [Axios](https://github.com/axios/axios) | +| [`@kubb/plugin-effect-httpapiclient`](./packages/plugin-effect-httpapiclient) | [![npm version](https://img.shields.io/npm/v/@kubb/plugin-effect-httpapiclient.svg)](https://npmx.dev/package/@kubb/plugin-effect-httpapiclient) | [Effect](https://github.com/Effect-TS/effect-smol) v4 HttpApiClient generation | | [`@kubb/plugin-fetch`](./packages/plugin-fetch) | [![npm version](https://img.shields.io/npm/v/@kubb/plugin-fetch.svg)](https://npmx.dev/package/@kubb/plugin-fetch) | Type-safe HTTP client based on the Fetch API | ### Zod diff --git a/examples/effect-httpapiclient/kubb.config.js b/examples/effect-httpapiclient/kubb.config.js new file mode 100644 index 000000000..9d65784db --- /dev/null +++ b/examples/effect-httpapiclient/kubb.config.js @@ -0,0 +1,21 @@ +import { adapterOas } from '@kubb/adapter-oas' +import { pluginEffect } from '@kubb/plugin-effect' +import { pluginEffectHttpApiClient } from '@kubb/plugin-effect-httpapiclient' +import { defineConfig } from 'kubb/config' + +export default defineConfig({ + root: '.', + input: './petStore.yaml', + adapter: adapterOas({ unknownType: 'unknown', dateType: 'date' }), + output: { + path: './src/gen', + clean: true, + }, + plugins: [ + pluginEffect({ output: { path: 'effect', barrel: { type: 'named' } } }), + pluginEffectHttpApiClient({ + output: { path: 'effectHttpApiClient', barrel: { type: 'named' } }, + baseURL: 'https://petstore.swagger.io/v2', + }), + ], +}) diff --git a/examples/effect-httpapiclient/package.json b/examples/effect-httpapiclient/package.json new file mode 100644 index 000000000..68ab3d5ea --- /dev/null +++ b/examples/effect-httpapiclient/package.json @@ -0,0 +1,33 @@ +{ + "name": "effect-httpapiclient-pet-store", + "version": "0.0.0", + "private": true, + "description": "Effect v4 HttpApiClient PetStore example", + "license": "MIT", + "author": "stijnvanhulle", + "repository": { + "type": "git", + "url": "https://github.com/kubb-labs/plugins.git", + "directory": "examples/effect-httpapiclient" + }, + "type": "module", + "sideEffects": false, + "scripts": { + "generate": "KUBB_DISABLE_TELEMETRY=1 kubb --config kubb.config.js", + "test": "vitest --passWithNoTests", + "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@kubb/adapter-oas": "catalog:", + "@kubb/plugin-effect": "workspace:*", + "@kubb/plugin-effect-httpapiclient": "workspace:*", + "effect": "catalog:", + "kubb": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22", + "pnpm": ">=11.0.0" + }, + "packageManager": "pnpm@11.5.0" +} diff --git a/examples/effect-httpapiclient/petStore.yaml b/examples/effect-httpapiclient/petStore.yaml new file mode 100644 index 000000000..f671f6e86 --- /dev/null +++ b/examples/effect-httpapiclient/petStore.yaml @@ -0,0 +1,159 @@ +openapi: 3.0.3 +info: + title: Effect HttpApiClient PetStore + version: 1.0.0 +paths: + /pets/{pet_id}: + get: + operationId: getPet + summary: Get one pet + tags: + - pet + parameters: + - name: pet_id + in: path + required: true + schema: + type: integer + format: int64 + - name: fields + in: query + style: pipeDelimited + explode: false + schema: + type: array + items: + type: string + - name: X-Trace + in: header + schema: + type: string + - name: session_id + in: cookie + style: form + explode: false + schema: + type: string + security: + - api_key: [] + - petstore_auth: + - read:pets + responses: + '200': + description: Pet found + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '404': + description: Pet not found + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /pets: + get: + operationId: listPets + summary: List pets + tags: + - pet + parameters: + - name: status + in: query + style: form + explode: true + schema: + type: array + items: + type: string + enum: + - available + - pending + security: + - petstore_auth: + - read:pets + - list:pets + responses: + '200': + description: Pet list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /orders: + post: + operationId: placeOrder + summary: Place an order + tags: + - store + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderInput' + responses: + '200': + description: Created order + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + updatedAt: + type: string + format: date-time + OrderInput: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + Order: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + shipDate: + type: string + format: date-time + ApiError: + type: object + required: + - message + properties: + message: + type: string + securitySchemes: + api_key: + type: apiKey + name: x-api-key + in: header + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/oauth/authorize + scopes: + read:pets: Read pets + list:pets: List pets diff --git a/examples/effect-httpapiclient/src/client.test.ts b/examples/effect-httpapiclient/src/client.test.ts new file mode 100644 index 000000000..9e2d9ce68 --- /dev/null +++ b/examples/effect-httpapiclient/src/client.test.ts @@ -0,0 +1,114 @@ +import * as DateTime from 'effect/DateTime' +import { Effect, Redacted } from 'effect' +import { HttpClient, HttpClientResponse } from 'effect/unstable/http' +import { describe, expect, test } from 'vitest' +import { ApiClient, makeSecurityLayer, type SecurityCredentialRequest, type SecurityCredential } from './gen/effectHttpApiClient/index.ts' + +type CapturedRequest = { + method: string + url: string + apiKey: string | undefined + cookie: string | undefined +} + +function makeMockClient({ body, captured }: { body: unknown; captured: Array }): HttpClient.HttpClient { + return HttpClient.make((request, url) => { + captured.push({ method: request.method, url: url.toString(), apiKey: request.headers['x-api-key'], cookie: request.headers.cookie }) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + }) +} + +describe('generated Effect HttpApiClient', () => { + test('encodes path parameters and injects a static API key', async () => { + const captured: Array = [] + const httpClient = makeMockClient({ body: { id: '10', name: 'Milo', photoUrls: [] }, captured }) + const program = Effect.gen(function* () { + const client = yield* ApiClient + return yield* client.pet.getPet({ + params: { petId: 10n }, + query: { fields: ['id', 'name'] }, + headers: { cookies: { sessionId: 'session' } }, + }) + }).pipe( + Effect.provide( + makeSecurityLayer({ + credentials: { + api_key: { _tag: 'ApiKey', value: Redacted.make('secret') }, + }, + }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + ) + + await expect(Effect.runPromise(program)).resolves.toMatchObject({ id: 10n, name: 'Milo' }) + expect(captured).toStrictEqual([ + { + method: 'GET', + url: 'https://petstore.swagger.io/v2/pets/10?fields=id%7Cname', + apiKey: 'secret', + cookie: 'session_id=session', + }, + ]) + }) + + test('passes endpoint scopes to a dynamic credential resolver', async () => { + const captured: Array = [] + const requests: Array = [] + const httpClient = makeMockClient({ body: [], captured }) + const resolve = (request: SecurityCredentialRequest): Effect.Effect => { + requests.push(request) + return Effect.succeed({ _tag: 'Bearer', token: Redacted.make('token') }) + } + const program = Effect.gen(function* () { + const client = yield* ApiClient + return yield* client.pet.listPets({ query: { status: ['available', 'pending'] } }) + }).pipe(Effect.provide(makeSecurityLayer({ resolve })), Effect.provideService(HttpClient.HttpClient, httpClient)) + + await Effect.runPromise(program) + expect(requests).toStrictEqual([ + { + endpoint: 'listPets', + scheme: 'petstore_auth', + scopes: ['read:pets', 'list:pets'], + }, + ]) + expect(captured[0]?.url).toBe('https://petstore.swagger.io/v2/pets?status=available&status=pending') + }) + + test('fails before transport when no security alternative is complete', async () => { + const captured: Array = [] + const httpClient = makeMockClient({ body: {}, captured }) + const program = Effect.gen(function* () { + const client = yield* ApiClient + return yield* client.pet.getPet({ params: { petId: 10n }, query: {}, headers: { cookies: {} } }) + }).pipe(Effect.provide(makeSecurityLayer()), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flip) + + await expect(Effect.runPromise(program)).resolves.toMatchObject({ + _tag: 'MissingSecurityCredentials', + endpoint: 'getPet', + requirements: [['api_key'], ['petstore_auth']], + }) + expect(captured).toStrictEqual([]) + }) + + test('decodes date-time responses as DateTime.Utc', async () => { + const captured: Array = [] + const httpClient = makeMockClient({ body: { id: '10', shipDate: '2026-07-14T10:30:00.000Z' }, captured }) + const program = Effect.gen(function* () { + const client = yield* ApiClient + return yield* client.store.placeOrder({ payload: { id: 10n } }) + }).pipe(Effect.provide(makeSecurityLayer()), Effect.provideService(HttpClient.HttpClient, httpClient)) + + const order = await Effect.runPromise(program) + expect(order.shipDate && DateTime.isUtc(order.shipDate)).toBe(true) + expect(order.shipDate && DateTime.formatIso(order.shipDate)).toBe('2026-07-14T10:30:00.000Z') + }) +}) diff --git a/examples/effect-httpapiclient/src/gen/effect/ApiError.ts b/examples/effect-httpapiclient/src/gen/effect/ApiError.ts new file mode 100644 index 000000000..9f18a0d82 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/ApiError.ts @@ -0,0 +1,10 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type ApiError = { readonly message: string } + +export const ApiError = Schema.Struct({ message: Schema.String }) diff --git a/examples/effect-httpapiclient/src/gen/effect/GetPet.ts b/examples/effect-httpapiclient/src/gen/effect/GetPet.ts new file mode 100644 index 000000000..9924a19b1 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/GetPet.ts @@ -0,0 +1,40 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { ApiError } from './ApiError' +import { Pet } from './Pet' + +export type GetPetPathPetId = bigint + +export const GetPetPathPetId = Schema.BigInt.annotate({ format: 'int64' }) + +export type GetPetQueryFields = ReadonlyArray | undefined + +export const GetPetQueryFields = Schema.UndefinedOr(Schema.Array(Schema.String)) + +export type GetPetHeaderXTrace = string | undefined + +export const GetPetHeaderXTrace = Schema.UndefinedOr(Schema.String) + +export type GetPetCookieSessionId = string | undefined + +export const GetPetCookieSessionId = Schema.UndefinedOr(Schema.String) + +export type GetPetStatus200 = Pet + +export const GetPetStatus200 = Pet + +export type GetPetStatus404 = ApiError + +export const GetPetStatus404 = ApiError + +export type GetPetResponse = GetPetStatus200 + +export const GetPetResponse = GetPetStatus200 + +export type GetPetError = GetPetStatus404 + +export const GetPetError = GetPetStatus404 diff --git a/examples/effect-httpapiclient/src/gen/effect/ListPets.ts b/examples/effect-httpapiclient/src/gen/effect/ListPets.ts new file mode 100644 index 000000000..d8ae05636 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/ListPets.ts @@ -0,0 +1,19 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Pet } from './Pet' + +export type ListPetsQueryStatus = ReadonlyArray<'available' | 'pending'> | undefined + +export const ListPetsQueryStatus = Schema.UndefinedOr(Schema.Array(Schema.Literals(['available', 'pending']))) + +export type ListPetsStatus200 = ReadonlyArray + +export const ListPetsStatus200 = Schema.Array(Pet) + +export type ListPetsResponse = ListPetsStatus200 + +export const ListPetsResponse = ListPetsStatus200 diff --git a/examples/effect-httpapiclient/src/gen/effect/Order.ts b/examples/effect-httpapiclient/src/gen/effect/Order.ts new file mode 100644 index 000000000..a78e737c8 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/Order.ts @@ -0,0 +1,14 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as DateTime from 'effect/DateTime' +import * as Schema from 'effect/Schema' + +export type Order = { readonly id: bigint; readonly shipDate?: DateTime.Utc } + +export const Order = Schema.Struct({ + id: Schema.BigInt.annotate({ format: 'int64' }), + shipDate: Schema.optionalKey(Schema.DateTimeUtcFromString.annotate({ format: 'date-time' })), +}) diff --git a/examples/effect-httpapiclient/src/gen/effect/OrderInput.ts b/examples/effect-httpapiclient/src/gen/effect/OrderInput.ts new file mode 100644 index 000000000..eef136345 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/OrderInput.ts @@ -0,0 +1,10 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' + +export type OrderInput = { readonly id: bigint } + +export const OrderInput = Schema.Struct({ id: Schema.BigInt.annotate({ format: 'int64' }) }) diff --git a/examples/effect-httpapiclient/src/gen/effect/Pet.ts b/examples/effect-httpapiclient/src/gen/effect/Pet.ts new file mode 100644 index 000000000..3fa937190 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/Pet.ts @@ -0,0 +1,15 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as DateTime from 'effect/DateTime' +import * as Schema from 'effect/Schema' + +export type Pet = { readonly id: bigint; readonly name: string; readonly updatedAt?: DateTime.Utc } + +export const Pet = Schema.Struct({ + id: Schema.BigInt.annotate({ format: 'int64' }), + name: Schema.String, + updatedAt: Schema.optionalKey(Schema.DateTimeUtcFromString.annotate({ format: 'date-time' })), +}) diff --git a/examples/effect-httpapiclient/src/gen/effect/PlaceOrder.ts b/examples/effect-httpapiclient/src/gen/effect/PlaceOrder.ts new file mode 100644 index 000000000..6b7a14b5a --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/PlaceOrder.ts @@ -0,0 +1,19 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import { Order } from './Order' +import { OrderInput } from './OrderInput' + +export type PlaceOrderStatus200 = Order + +export const PlaceOrderStatus200 = Order + +export type PlaceOrderResponse = PlaceOrderStatus200 + +export const PlaceOrderResponse = PlaceOrderStatus200 + +export type PlaceOrderBody = OrderInput + +export const PlaceOrderBody = OrderInput diff --git a/examples/effect-httpapiclient/src/gen/effect/index.ts b/examples/effect-httpapiclient/src/gen/effect/index.ts new file mode 100644 index 000000000..080a7316f --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effect/index.ts @@ -0,0 +1,16 @@ +export { ApiError } from './ApiError' +export { + GetPetCookieSessionId, + GetPetError, + GetPetHeaderXTrace, + GetPetPathPetId, + GetPetQueryFields, + GetPetResponse, + GetPetStatus200, + GetPetStatus404, +} from './GetPet' +export { ListPetsQueryStatus, ListPetsResponse, ListPetsStatus200 } from './ListPets' +export { Order } from './Order' +export { OrderInput } from './OrderInput' +export { Pet } from './Pet' +export { PlaceOrderBody, PlaceOrderResponse, PlaceOrderStatus200 } from './PlaceOrder' diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/api.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/api.ts new file mode 100644 index 000000000..bae338a9c --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/api.ts @@ -0,0 +1,27 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import { getPetEndpoint } from './getPetEndpoint' +import { listPetsEndpoint } from './listPetsEndpoint' +import { placeOrderEndpoint } from './placeOrderEndpoint' +import { HttpApi, HttpApiGroup, OpenApi } from 'effect/unstable/httpapi' + +/** + * HttpApi group for the pet operations. + */ +export const PetGroup = HttpApiGroup.make('pet').add(getPetEndpoint).add(listPetsEndpoint) + +/** + * HttpApi group for the store operations. + */ +export const StoreGroup = HttpApiGroup.make('store').add(placeOrderEndpoint) + +/** + * Root Effect HttpApi contract. + */ +export const Api = HttpApi.make('effectHttpApiClientPetStore') + .add(PetGroup) + .add(StoreGroup) + .annotateMerge(OpenApi.annotations({ title: 'Effect HttpApiClient PetStore', version: '1.0.0' })) diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/apiClient.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/apiClient.ts new file mode 100644 index 000000000..ad55686a9 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/apiClient.ts @@ -0,0 +1,17 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import { Api } from './api' +import { HttpApiClient } from 'effect/unstable/httpapi' + +/** + * Client methods derived from the generated HttpApi contract. + */ +export type ApiClient = HttpApiClient.ForApi + +/** + * Effect that constructs the generated HttpApi client. + */ +export const ApiClient = HttpApiClient.make(Api, { baseUrl: 'https://petstore.swagger.io/v2' }) diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/cookieParameters.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/cookieParameters.ts new file mode 100644 index 000000000..5c4086aa4 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/cookieParameters.ts @@ -0,0 +1,90 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Cookies } from 'effect/unstable/http' +import { Effect, Option, SchemaGetter, SchemaIssue } from 'effect' + +/** + * Metadata for one generated header or cookie parameter. + */ +export type HeaderParameter = { + readonly name: string + readonly location: 'header' | 'cookie' + readonly explode: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function valueToString(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +function serializeHeaderValue({ value, explode }: { value: unknown; explode: boolean }): string { + if (Array.isArray(value)) return value.map(valueToString).join(',') + if (!isRecord(value)) return valueToString(value) + const entries = Object.entries(value) + if (explode) return entries.map(([key, item]) => key + '=' + valueToString(item)).join(',') + return entries.flatMap(([key, item]) => [key, valueToString(item)]).join(',') +} + +function serializeCookieValue({ name, value, explode }: { name: string; value: unknown; explode: boolean }): string { + if (Array.isArray(value)) { + const values = value.map((item) => encodeURIComponent(valueToString(item))) + return explode ? values.map((item) => name + '=' + item).join('; ') : name + '=' + values.join(',') + } + if (isRecord(value)) { + const entries = Object.entries(value) + if (explode) return entries.map(([key, item]) => key + '=' + encodeURIComponent(valueToString(item))).join('; ') + const encoded = entries + .flatMap(([key, item]) => [key, valueToString(item)]) + .map(encodeURIComponent) + .join(',') + return name + '=' + encoded + } + return name + '=' + encodeURIComponent(valueToString(value)) +} + +function invalidHeaders(value: unknown) { + return new SchemaIssue.InvalidValue(Option.some(value), { message: 'Expected encoded headers to be an object' }) +} + +/** + * Encodes typed cookie parameters into the Cookie header while retaining typed header fields. + */ +export function headersWithCookies(schema: S, parameters: ReadonlyArray) { + const encodedHeaders = Schema.Record(Schema.String, Schema.String) + return encodedHeaders.pipe( + Schema.decodeTo(schema, { + decode: SchemaGetter.transformOrFail((headers) => { + const decoded: Record = { ...headers, cookies: Cookies.parseHeader(headers.cookie ?? '') } + return Schema.decodeUnknownEffect(Schema.toEncoded(schema))(decoded).pipe(Effect.mapError((error) => error.issue)) + }), + encode: SchemaGetter.transformOrFail((value) => { + if (!isRecord(value)) return Effect.fail(invalidHeaders(value)) + const headers: Record = {} + const cookies = isRecord(value.cookies) ? value.cookies : {} + const cookieParts: Array = [] + for (const parameter of parameters) { + const source = parameter.location === 'cookie' ? cookies : value + const item = source[parameter.name] + if (item === undefined || item === null) continue + if (parameter.location === 'cookie') { + cookieParts.push(serializeCookieValue({ name: parameter.name, value: item, explode: parameter.explode })) + } else { + headers[parameter.name] = serializeHeaderValue({ value: item, explode: parameter.explode }) + } + } + const existingCookie = headers.cookie + if (cookieParts.length) headers.cookie = existingCookie ? existingCookie + '; ' + cookieParts.join('; ') : cookieParts.join('; ') + return Effect.succeed(headers) + }), + }), + ) +} diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/getPetEndpoint.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/getPetEndpoint.ts new file mode 100644 index 000000000..8ba69da59 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/getPetEndpoint.ts @@ -0,0 +1,41 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { GetPetPathPetId, GetPetQueryFields, GetPetHeaderXTrace, GetPetCookieSessionId, GetPetStatus200, GetPetStatus404 } from '../effect/GetPet' +import { headersWithCookies } from './cookieParameters' +import { pathParameter, queryParameter } from './parameterSerialization' +import { ApiSecurity } from './security' +import { HttpApiEndpoint, HttpApiSchema, OpenApi } from 'effect/unstable/httpapi' + +/** + * Get one pet + */ +export const getPetEndpoint = HttpApiEndpoint.get('getPet', '/pets/:pet_id', { + params: Schema.Struct({ + petId: pathParameter(GetPetPathPetId, { name: 'pet_id', kind: 'primitive', style: 'simple', explode: false }), + }).pipe(Schema.encodeKeys({ petId: 'pet_id' })), + query: Schema.Struct({ + fields: Schema.optionalKey(queryParameter(GetPetQueryFields, { name: 'fields', kind: 'array', style: 'pipeDelimited', explode: false })), + }), + headers: headersWithCookies( + Schema.Struct({ + xTrace: Schema.optionalKey(GetPetHeaderXTrace), + cookies: Schema.optionalKey( + Schema.Struct({ + sessionId: Schema.optionalKey(GetPetCookieSessionId), + }).pipe(Schema.encodeKeys({ sessionId: 'session_id' })), + ), + }).pipe(Schema.encodeKeys({ xTrace: 'X-Trace' })), + [ + { name: 'X-Trace', location: 'header', explode: false }, + { name: 'session_id', location: 'cookie', explode: false }, + ], + ), + success: [GetPetStatus200.pipe(HttpApiSchema.asJson({ contentType: 'application/json' })).pipe(HttpApiSchema.status(200))], + error: [GetPetStatus404.pipe(HttpApiSchema.asJson({ contentType: 'application/json' })).pipe(HttpApiSchema.status(404))], +}) + .middleware(ApiSecurity) + .annotateMerge(OpenApi.annotations({ summary: 'Get one pet', override: { security: [{ api_key: [] }, { petstore_auth: ['read:pets'] }] } })) diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/index.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/index.ts new file mode 100644 index 000000000..6cacd70cb --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/index.ts @@ -0,0 +1,11 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +export * from './api' +export * from './apiClient' +export * from './getPetEndpoint' +export * from './listPetsEndpoint' +export * from './placeOrderEndpoint' +export * from './security' diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/listPetsEndpoint.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/listPetsEndpoint.ts new file mode 100644 index 000000000..149e14e48 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/listPetsEndpoint.ts @@ -0,0 +1,22 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { ListPetsQueryStatus, ListPetsStatus200 } from '../effect/ListPets' +import { queryParameter } from './parameterSerialization' +import { ApiSecurity } from './security' +import { HttpApiEndpoint, HttpApiSchema, OpenApi } from 'effect/unstable/httpapi' + +/** + * List pets + */ +export const listPetsEndpoint = HttpApiEndpoint.get('listPets', '/pets', { + query: Schema.Struct({ + status: Schema.optionalKey(queryParameter(ListPetsQueryStatus, { name: 'status', kind: 'array', style: 'form', explode: true })), + }), + success: [ListPetsStatus200.pipe(HttpApiSchema.asJson({ contentType: 'application/json' })).pipe(HttpApiSchema.status(200))], +}) + .middleware(ApiSecurity) + .annotateMerge(OpenApi.annotations({ summary: 'List pets', override: { security: [{ petstore_auth: ['read:pets', 'list:pets'] }] } })) diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/parameterSerialization.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/parameterSerialization.ts new file mode 100644 index 000000000..f860c4a93 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/parameterSerialization.ts @@ -0,0 +1,154 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import * as Schema from 'effect/Schema' +import { Effect, Option, SchemaGetter, SchemaIssue } from 'effect' + +type ParameterKind = 'primitive' | 'array' | 'object' + +type ParameterOptions = { + readonly name: string + readonly kind: ParameterKind + readonly style: 'matrix' | 'label' | 'form' | 'simple' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject' + readonly explode: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function valueToString(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +function serializePath({ value, options }: { value: unknown; options: ParameterOptions }): string { + const { explode, name, style } = options + if (Array.isArray(value)) { + const values = value.map(valueToString) + if (style === 'label') return '.' + values.join(explode ? '.' : ',') + if (style === 'matrix') return explode ? values.map((item) => ';' + name + '=' + item).join('') : ';' + name + '=' + values.join(',') + return values.join(',') + } + if (isRecord(value)) { + const entries = Object.entries(value) + const members = entries.map(([key, item]) => (explode ? key + '=' + valueToString(item) : key + ',' + valueToString(item))) + if (style === 'label') return '.' + members.join(explode ? '.' : ',') + if (style === 'matrix') return explode ? members.map((member) => ';' + member).join('') : ';' + name + '=' + members.join(',') + return members.join(',') + } + const serialized = valueToString(value) + if (style === 'label') return '.' + serialized + if (style === 'matrix') return ';' + name + '=' + serialized + return serialized +} + +function serializeQuery({ value, options }: { value: unknown; options: ParameterOptions }): string | Array | Record { + if (Array.isArray(value)) { + const values = value.map(valueToString) + if (options.explode) return values + const delimiter = options.style === 'spaceDelimited' ? ' ' : options.style === 'pipeDelimited' ? '|' : ',' + return values.join(delimiter) + } + if (isRecord(value)) { + if (options.style === 'deepObject' || options.explode) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, valueToString(item)])) + return Object.entries(value) + .flatMap(([key, item]) => [key, valueToString(item)]) + .join(',') + } + return valueToString(value) +} + +function serializeHeader({ value, explode }: { value: unknown; explode: boolean }): string { + if (Array.isArray(value)) return value.map(valueToString).join(',') + if (!isRecord(value)) return valueToString(value) + const entries = Object.entries(value) + if (explode) return entries.map(([key, item]) => key + '=' + valueToString(item)).join(',') + return entries.flatMap(([key, item]) => [key, valueToString(item)]).join(',') +} + +function deserialize({ value, options }: { value: unknown; options: ParameterOptions }): unknown { + if (options.kind === 'primitive' || typeof value !== 'string') return value + let serialized = value + if (options.style === 'label') serialized = serialized.slice(1) + if (options.style === 'matrix') { + serialized = serialized.startsWith(';') ? serialized.slice(1) : serialized + if (!options.explode && serialized.startsWith(options.name + '=')) serialized = serialized.slice(options.name.length + 1) + } + if (options.kind === 'array') { + const delimiter = + options.style === 'spaceDelimited' ? ' ' : options.style === 'pipeDelimited' ? '|' : options.style === 'label' && options.explode ? '.' : ',' + return serialized.split(delimiter).map((item) => { + if (options.style === 'matrix' && options.explode && item.startsWith(options.name + '=')) return item.slice(options.name.length + 1) + return item + }) + } + const delimiter = options.style === 'label' && options.explode ? '.' : options.style === 'matrix' && options.explode ? ';' : ',' + const parts = serialized.split(delimiter) + const entries: Array<[string, string]> = [] + if (options.explode) { + for (const part of parts) { + const separator = part.indexOf('=') + if (separator !== -1) entries.push([part.slice(0, separator), part.slice(separator + 1)]) + } + } else { + for (let index = 0; index < parts.length; index += 2) { + const key = parts[index] + const item = parts[index + 1] + if (key !== undefined && item !== undefined) entries.push([key, item]) + } + } + return Object.fromEntries(entries) +} + +function invalidParameter(value: unknown) { + return new SchemaIssue.InvalidValue(Option.some(value), { message: 'Could not serialize the OpenAPI parameter' }) +} + +function parameterCodec({ + encoded, + schema, + options, + serialize, +}: { + encoded: E + schema: S + options: ParameterOptions + serialize(value: S['Encoded']): E['Type'] +}) { + return Schema.decodeTo(schema, { + decode: SchemaGetter.transform((value) => deserialize({ value, options }) as S['Encoded']), + encode: SchemaGetter.transformOrFail((value) => { + try { + return Effect.succeed(serialize(value)) + } catch { + return Effect.fail(invalidParameter(value)) + } + }), + })(encoded) +} + +/** + * Applies OpenAPI simple, label, or matrix serialization to a path parameter. + */ +export function pathParameter(schema: S, options: ParameterOptions) { + return parameterCodec({ encoded: Schema.String, schema, options, serialize: (value) => serializePath({ value, options }) }) +} + +/** + * Applies OpenAPI form, delimited, or deep-object serialization to a query parameter. + */ +export function queryParameter(schema: S, options: ParameterOptions) { + const encoded = Schema.Union([Schema.String, Schema.Array(Schema.String), Schema.Record(Schema.String, Schema.String)]) + return parameterCodec({ encoded, schema, options, serialize: (value) => serializeQuery({ value, options }) }) +} + +/** + * Applies OpenAPI simple serialization to a header parameter. + */ +export function headerParameter(schema: S, options: ParameterOptions) { + return parameterCodec({ encoded: Schema.String, schema, options, serialize: (value) => serializeHeader({ value, explode: options.explode }) }) +} diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/placeOrderEndpoint.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/placeOrderEndpoint.ts new file mode 100644 index 000000000..d9bedf0a1 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/placeOrderEndpoint.ts @@ -0,0 +1,15 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import { PlaceOrderBody, PlaceOrderStatus200 } from '../effect/PlaceOrder' +import { HttpApiEndpoint, HttpApiSchema, OpenApi } from 'effect/unstable/httpapi' + +/** + * Place an order + */ +export const placeOrderEndpoint = HttpApiEndpoint.post('placeOrder', '/orders', { + payload: PlaceOrderBody.pipe(HttpApiSchema.asJson({ contentType: 'application/json' })), + success: [PlaceOrderStatus200.pipe(HttpApiSchema.asJson({ contentType: 'application/json' })).pipe(HttpApiSchema.status(200))], +}).annotateMerge(OpenApi.annotations({ summary: 'Place an order' })) diff --git a/examples/effect-httpapiclient/src/gen/effectHttpApiClient/security.ts b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/security.ts new file mode 100644 index 000000000..53bc35972 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/effectHttpApiClient/security.ts @@ -0,0 +1,235 @@ +/** + * Generated by Kubb (https://kubb.dev/). + * Do not edit manually. + */ + +import { HttpClientRequest } from 'effect/unstable/http' +import { HttpApiMiddleware } from 'effect/unstable/httpapi' +import { Data, Effect, Redacted } from 'effect' + +type SecurityScheme = + | { + readonly credential: 'apiKey' + readonly in: 'header' | 'query' | 'cookie' + readonly wireName: string + } + | { + readonly credential: 'basic' | 'bearer' | 'authorization' + } + +type SecurityRequirementEntry = { + readonly name: SecuritySchemeName + readonly scopes: ReadonlyArray +} + +type ResolvedCredential = { + readonly scheme: SecurityScheme + readonly credential: SecurityCredential +} + +/** + * Names declared under OpenAPI components.securitySchemes and used by this client. + */ +export type SecuritySchemeName = 'api_key' | 'petstore_auth' + +/** + * Credential supplied for an OpenAPI API key scheme. + */ +export type ApiKeyCredential = { + readonly _tag: 'ApiKey' + readonly value: Redacted.Redacted +} + +/** + * Credential supplied for HTTP Basic authentication. + */ +export type BasicCredential = { + readonly _tag: 'Basic' + readonly username: string | Redacted.Redacted + readonly password: Redacted.Redacted +} + +/** + * Credential supplied for bearer, OAuth2, or OpenID Connect authentication. + */ +export type BearerCredential = { + readonly _tag: 'Bearer' + readonly token: Redacted.Redacted +} + +/** + * Complete Authorization header value supplied for another HTTP authentication scheme. + */ +export type AuthorizationCredential = { + readonly _tag: 'Authorization' + readonly value: Redacted.Redacted +} + +/** + * Credential accepted by the generated security resolver. + */ +export type SecurityCredential = ApiKeyCredential | BasicCredential | BearerCredential | AuthorizationCredential + +/** + * Static credentials keyed by their original OpenAPI security scheme names. + */ +export type SecurityCredentials = { + readonly api_key?: ApiKeyCredential + readonly petstore_auth?: BearerCredential +} + +/** + * Context passed to a dynamic credential resolver for each required scheme. + */ +export type SecurityCredentialRequest = { + readonly endpoint: string + readonly scheme: SecuritySchemeName + readonly scopes: ReadonlyArray +} + +/** + * Resolves a credential at request time, which supports token refresh and scoped credentials. + */ +export type SecurityCredentialResolver = (request: SecurityCredentialRequest) => Effect.Effect + +/** + * Options for the generated security client Layer. + */ +export type SecurityLayerOptions = { + /** + * Static credentials checked before the dynamic resolver. + */ + readonly credentials?: SecurityCredentials + /** + * Dynamic fallback used when a static credential is absent. + */ + readonly resolve?: SecurityCredentialResolver +} + +/** + * Indicates that no complete OpenAPI security alternative could be satisfied. + */ +export class MissingSecurityCredentials extends Data.TaggedError('MissingSecurityCredentials')<{ + readonly endpoint: string + readonly requirements: ReadonlyArray> +}> {} + +/** + * Wraps a failure raised by the dynamic credential resolver. + */ +export class SecurityCredentialResolutionError extends Data.TaggedError('SecurityCredentialResolutionError')<{ + readonly endpoint: string + readonly scheme: SecuritySchemeName + readonly cause: unknown +}> {} + +/** + * Client middleware marker attached to secured endpoints. + */ +export class ApiSecurity extends HttpApiMiddleware.Service()( + 'kubb/ApiSecurity', + { requiredForClient: true }, +) {} + +const securitySchemes: Readonly> = { + api_key: { credential: 'apiKey', in: 'header', wireName: 'x-api-key' }, + petstore_auth: { credential: 'bearer' }, +} + +const securityRequirements: Readonly>>> = { + getPet: [[{ name: 'api_key', scopes: [] }], [{ name: 'petstore_auth', scopes: ['read:pets'] }]], + listPets: [[{ name: 'petstore_auth', scopes: ['read:pets', 'list:pets'] }]], +} + +function credentialMatchesScheme({ credential, scheme }: { credential: SecurityCredential; scheme: SecurityScheme }): boolean { + if (scheme.credential === 'apiKey') return credential._tag === 'ApiKey' + if (scheme.credential === 'basic') return credential._tag === 'Basic' + if (scheme.credential === 'bearer') return credential._tag === 'Bearer' + return credential._tag === 'Authorization' +} + +function resolveCredential({ + endpoint, + entry, + options, +}: { + endpoint: string + entry: SecurityRequirementEntry + options: SecurityLayerOptions +}): Effect.Effect { + const scheme = securitySchemes[entry.name] + const credential = options.credentials?.[entry.name] + if (credential) { + return credentialMatchesScheme({ credential, scheme }) + ? Effect.succeed(credential) + : Effect.fail(new SecurityCredentialResolutionError({ endpoint, scheme: entry.name, cause: 'Credential type does not match its scheme' })) + } + if (!options.resolve) return Effect.succeed(undefined) + + return options.resolve({ endpoint, scheme: entry.name, scopes: entry.scopes }).pipe( + Effect.mapError((cause) => new SecurityCredentialResolutionError({ endpoint, scheme: entry.name, cause })), + Effect.flatMap((resolved) => { + if (!resolved || credentialMatchesScheme({ credential: resolved, scheme })) return Effect.succeed(resolved) + return Effect.fail(new SecurityCredentialResolutionError({ endpoint, scheme: entry.name, cause: 'Credential type does not match its scheme' })) + }), + ) +} + +function resolveAlternative({ + endpoint, + requirement, + options, +}: { + endpoint: string + requirement: ReadonlyArray + options: SecurityLayerOptions +}): Effect.Effect | undefined, SecurityCredentialResolutionError, R> { + return Effect.gen(function* () { + const resolved: Array = [] + for (const entry of requirement) { + const credential = yield* resolveCredential({ endpoint, entry, options }) + if (!credential) return undefined + resolved.push({ scheme: securitySchemes[entry.name], credential }) + } + return resolved + }) +} + +function applyCredential({ request, resolved }: { request: HttpClientRequest.HttpClientRequest; resolved: ResolvedCredential }) { + const { credential, scheme } = resolved + if (credential._tag === 'Basic') return HttpClientRequest.basicAuth(request, credential.username, credential.password) + if (credential._tag === 'Bearer') return HttpClientRequest.bearerToken(request, credential.token) + if (credential._tag === 'Authorization') return HttpClientRequest.setHeader(request, 'Authorization', Redacted.value(credential.value)) + + if (scheme.credential !== 'apiKey') throw new Error('API key credential resolved with a non-API-key scheme') + const value = Redacted.value(credential.value) + if (scheme.in === 'header') return HttpClientRequest.setHeader(request, scheme.wireName, value) + if (scheme.in === 'query') return HttpClientRequest.setUrlParam(request, scheme.wireName, value) + + const cookie = `${encodeURIComponent(scheme.wireName)}=${encodeURIComponent(value)}` + const previous = request.headers.cookie + return HttpClientRequest.setHeader(request, 'Cookie', previous ? `${previous}; ${cookie}` : cookie) +} + +/** + * Creates the client middleware Layer used by secured generated endpoints. + */ +export function makeSecurityLayer(options: SecurityLayerOptions = {}) { + return HttpApiMiddleware.layerClient(ApiSecurity, ({ endpoint, request, next }) => + Effect.gen(function* () { + const requirements = securityRequirements[endpoint.identifier] ?? [] + for (const requirement of requirements) { + const resolved = yield* resolveAlternative({ endpoint: endpoint.identifier, requirement, options }) + if (!resolved) continue + return yield* next(resolved.reduce((current, credential) => applyCredential({ request: current, resolved: credential }), request)) + } + + return yield* Effect.fail( + new MissingSecurityCredentials({ + endpoint: endpoint.identifier, + requirements: requirements.map((requirement) => requirement.map((entry) => entry.name)), + }), + ) + }), + ) +} diff --git a/examples/effect-httpapiclient/src/gen/index.ts b/examples/effect-httpapiclient/src/gen/index.ts new file mode 100644 index 000000000..4e29f3ed1 --- /dev/null +++ b/examples/effect-httpapiclient/src/gen/index.ts @@ -0,0 +1,16 @@ +export { ApiError } from './effect/ApiError' +export { + GetPetCookieSessionId, + GetPetError, + GetPetHeaderXTrace, + GetPetPathPetId, + GetPetQueryFields, + GetPetResponse, + GetPetStatus200, + GetPetStatus404, +} from './effect/GetPet' +export { ListPetsQueryStatus, ListPetsResponse, ListPetsStatus200 } from './effect/ListPets' +export { Order } from './effect/Order' +export { OrderInput } from './effect/OrderInput' +export { Pet } from './effect/Pet' +export { PlaceOrderBody, PlaceOrderResponse, PlaceOrderStatus200 } from './effect/PlaceOrder' diff --git a/examples/effect-httpapiclient/src/index.ts b/examples/effect-httpapiclient/src/index.ts new file mode 100644 index 000000000..82daab1cd --- /dev/null +++ b/examples/effect-httpapiclient/src/index.ts @@ -0,0 +1 @@ +export * from './gen/index.ts' diff --git a/examples/effect-httpapiclient/tsconfig.json b/examples/effect-httpapiclient/tsconfig.json new file mode 100644 index 000000000..6607a3c81 --- /dev/null +++ b/examples/effect-httpapiclient/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["bun-types", "../../reset.d.ts"] + }, + "include": ["src/**/*", "kubb.config.js"] +} diff --git a/internals/shared/src/index.ts b/internals/shared/src/index.ts index 99db79c90..8c93fd219 100644 --- a/internals/shared/src/index.ts +++ b/internals/shared/src/index.ts @@ -13,6 +13,7 @@ export { getRequestGroups, getResponseContentTypeInfo, getResponseType, + getStatusCodeNumber, getSuccessResponses, isErrorStatusCode, isEventStream, diff --git a/packages/plugin-effect-httpapiclient/CHANGELOG.md b/packages/plugin-effect-httpapiclient/CHANGELOG.md new file mode 100644 index 000000000..227aa43aa --- /dev/null +++ b/packages/plugin-effect-httpapiclient/CHANGELOG.md @@ -0,0 +1 @@ +# @kubb/plugin-effect-httpapiclient diff --git a/packages/plugin-effect-httpapiclient/README.md b/packages/plugin-effect-httpapiclient/README.md new file mode 100644 index 000000000..e563f24d5 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/README.md @@ -0,0 +1,80 @@ +
+ + Kubb banner + + +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![Stars][stars-src]][stars-href] +[![License][license-src]][license-href] +[![Node][node-src]][node-href] +
+ +# @kubb/plugin-effect-httpapiclient + +`@kubb/plugin-effect-httpapiclient` generates Effect v4 `HttpApi` contracts and a typed `HttpApiClient` from OpenAPI. The beta currently targets `effect@4.0.0-beta.98` and uses schemas generated by `@kubb/plugin-effect`. + +## Installation + +```bash +pnpm add -D @kubb/plugin-effect@beta @kubb/plugin-effect-httpapiclient@beta +pnpm add effect@4.0.0-beta.98 +``` + +## Usage + +```ts +import { pluginEffect } from '@kubb/plugin-effect' +import { pluginEffectHttpApiClient } from '@kubb/plugin-effect-httpapiclient' +import { defineConfig } from 'kubb/config' + +export default defineConfig({ + input: './petStore.yaml', + output: { path: './src/gen' }, + plugins: [pluginEffect(), pluginEffectHttpApiClient({ baseURL: 'https://petstore.example.com' })], +}) +``` + +The generated `ApiClient` is an Effect. Run it with an `HttpClient` service and the generated security Layer when the OpenAPI document protects any operations. + +```ts +import { Effect, Redacted } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' +import { ApiClient, makeSecurityLayer } from './gen/effectHttpApiClient/index.ts' + +const apiKey = process.env.API_KEY +if (!apiKey) throw new Error('API_KEY is required') + +const program = Effect.gen(function* () { + const client = yield* ApiClient + return yield* client.pet.getPetById({ params: { petId: 10n } }) +}).pipe( + Effect.provide( + makeSecurityLayer({ + credentials: { + api_key: { _tag: 'ApiKey', value: Redacted.make(apiKey) }, + }, + }), + ), + Effect.provide(FetchHttpClient.layer), +) +``` + +## Documentation + +See the [Effect HttpApiClient plugin documentation](https://kubb.dev/plugins/plugin-effect-httpapiclient) for options, content types, and authentication examples. + +## License + +[MIT](https://github.com/kubb-labs/plugins/blob/main/LICENSE) + +[npm-version-src]: https://shieldcn.dev/npm/v/@kubb/plugin-effect-httpapiclient.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[npm-version-href]: https://npmx.dev/package/@kubb/plugin-effect-httpapiclient +[npm-downloads-src]: https://shieldcn.dev/npm/dm/@kubb/plugin-effect-httpapiclient.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[npm-downloads-href]: https://npmx.dev/package/@kubb/plugin-effect-httpapiclient +[stars-src]: https://shieldcn.dev/github/stars/kubb-labs/kubb.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[stars-href]: https://github.com/kubb-labs/kubb +[license-src]: https://shieldcn.dev/npm/license/@kubb/plugin-effect-httpapiclient.svg?variant=secondary&size=xs&theme=zinc +[license-href]: https://github.com/kubb-labs/kubb/blob/main/LICENSE +[node-src]: https://shieldcn.dev/npm/node/@kubb/plugin-effect-httpapiclient.svg?variant=secondary&size=xs&theme=zinc&mode=dark +[node-href]: https://npmx.dev/package/@kubb/plugin-effect-httpapiclient diff --git a/packages/plugin-effect-httpapiclient/package.json b/packages/plugin-effect-httpapiclient/package.json new file mode 100644 index 000000000..d8e7e53ac --- /dev/null +++ b/packages/plugin-effect-httpapiclient/package.json @@ -0,0 +1,71 @@ +{ + "name": "@kubb/plugin-effect-httpapiclient", + "version": "5.0.0-beta.95", + "description": "Generate Effect v4 HttpApi contracts and HttpApiClient code from OpenAPI with Kubb.", + "keywords": [ + "client", + "code-generation", + "codegen", + "effect", + "http-api", + "kubb", + "openapi", + "typescript" + ], + "license": "MIT", + "author": "stijnvanhulle", + "repository": { + "type": "git", + "url": "git+https://github.com/kubb-labs/plugins.git", + "directory": "packages/plugin-effect-httpapiclient" + }, + "files": [ + "dist", + "!/**/**.test.**", + "!/**/__tests__/**", + "!/**/__snapshots__/**" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsdown", + "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", + "lint": "oxlint .", + "lint:fix": "oxlint --fix .", + "release": "pnpm publish --no-git-check", + "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", + "release:stage": "pnpm stage publish --no-git-check", + "start": "tsdown --watch", + "test": "vitest --passWithNoTests", + "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@kubb/plugin-effect": "workspace:*" + }, + "devDependencies": { + "@internals/shared": "workspace:*", + "@internals/utils": "workspace:*", + "effect": "catalog:", + "kubb": "catalog:" + }, + "peerDependencies": { + "kubb": "catalog:" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/plugin-effect-httpapiclient/src/cookieRuntime.ts b/packages/plugin-effect-httpapiclient/src/cookieRuntime.ts new file mode 100644 index 000000000..be1bd2fe0 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/cookieRuntime.ts @@ -0,0 +1,85 @@ +/** + * Renders the generated header codec that nests OpenAPI cookie parameters under `headers.cookies`. + */ +export function renderCookieRuntime(): string { + return ` +/** + * Metadata for one generated header or cookie parameter. + */ +export type HeaderParameter = { + readonly name: string + readonly location: 'header' | 'cookie' + readonly explode: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function valueToString(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +function serializeHeaderValue({ value, explode }: { value: unknown; explode: boolean }): string { + if (Array.isArray(value)) return value.map(valueToString).join(',') + if (!isRecord(value)) return valueToString(value) + const entries = Object.entries(value) + if (explode) return entries.map(([key, item]) => key + '=' + valueToString(item)).join(',') + return entries.flatMap(([key, item]) => [key, valueToString(item)]).join(',') +} + +function serializeCookieValue({ name, value, explode }: { name: string; value: unknown; explode: boolean }): string { + if (Array.isArray(value)) { + const values = value.map((item) => encodeURIComponent(valueToString(item))) + return explode ? values.map((item) => name + '=' + item).join('; ') : name + '=' + values.join(',') + } + if (isRecord(value)) { + const entries = Object.entries(value) + if (explode) return entries.map(([key, item]) => key + '=' + encodeURIComponent(valueToString(item))).join('; ') + const encoded = entries.flatMap(([key, item]) => [key, valueToString(item)]).map(encodeURIComponent).join(',') + return name + '=' + encoded + } + return name + '=' + encodeURIComponent(valueToString(value)) +} + +function invalidHeaders(value: unknown) { + return new SchemaIssue.InvalidValue(Option.some(value), { message: 'Expected encoded headers to be an object' }) +} + +/** + * Encodes typed cookie parameters into the Cookie header while retaining typed header fields. + */ +export function headersWithCookies(schema: S, parameters: ReadonlyArray) { + const encodedHeaders = Schema.Record(Schema.String, Schema.String) + return encodedHeaders.pipe( + Schema.decodeTo(schema, { + decode: SchemaGetter.transformOrFail((headers) => { + const decoded: Record = { ...headers, cookies: Cookies.parseHeader(headers.cookie ?? '') } + return Schema.decodeUnknownEffect(Schema.toEncoded(schema))(decoded).pipe(Effect.mapError((error) => error.issue)) + }), + encode: SchemaGetter.transformOrFail((value) => { + if (!isRecord(value)) return Effect.fail(invalidHeaders(value)) + const headers: Record = {} + const cookies = isRecord(value.cookies) ? value.cookies : {} + const cookieParts: Array = [] + for (const parameter of parameters) { + const source = parameter.location === 'cookie' ? cookies : value + const item = source[parameter.name] + if (item === undefined || item === null) continue + if (parameter.location === 'cookie') { + cookieParts.push(serializeCookieValue({ name: parameter.name, value: item, explode: parameter.explode })) + } else { + headers[parameter.name] = serializeHeaderValue({ value: item, explode: parameter.explode }) + } + } + const existingCookie = headers.cookie + if (cookieParts.length) headers.cookie = existingCookie ? existingCookie + '; ' + cookieParts.join('; ') : cookieParts.join('; ') + return Effect.succeed(headers) + }), + }), + ) +} +` +} diff --git a/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx new file mode 100644 index 000000000..5c48568a5 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx @@ -0,0 +1,144 @@ +import type { Adapter, Config } from 'kubb/kit' +import { ast, memoryStorage } from 'kubb/kit' +import { createMockedAdapter, createMockedPlugin, createMockedPluginDriver, renderGeneratorOperations } from 'kubb/kit/testing' +import type { PluginEffect } from '@kubb/plugin-effect' +import { resolverEffect } from '@kubb/plugin-effect' +import { describe, expect, test } from 'vitest' +import { rawSources } from '#mocks' +import { resolverEffectHttpApiClient } from '../resolvers/resolverEffectHttpApiClient.ts' +import type { SecurityDocument } from '../security.ts' +import type { PluginEffectHttpApiClient } from '../types.ts' +import { httpApiClientGenerator } from './httpApiClientGenerator.tsx' + +const testConfig: Config = { + root: '.', + input: '', + output: { path: 'test' }, + plugins: [], + parsers: [], + reporters: [], + adapter: createMockedAdapter(), + storage: memoryStorage(), +} + +const defaultOptions: PluginEffectHttpApiClient['resolvedOptions'] = { + output: { path: 'effectHttpApiClient', barrel: { type: 'named' } }, + exclude: [], + include: undefined, + override: [], + group: null, + baseURL: 'https://example.com', + mode: 'tag', +} + +const mockedEffectPlugin = createMockedPlugin({ + name: 'plugin-effect', + options: { output: { path: 'effect' }, group: null } as PluginEffect['resolvedOptions'], + resolver: resolverEffect, +}) + +const securityDocument: SecurityDocument = { + components: { + securitySchemes: { + apiKey: { type: 'apiKey', name: 'x-api-key', in: 'header' }, + }, + }, + paths: { + '/pets/{pet_id}': { + get: { security: [{ apiKey: [] }] }, + }, + }, +} + +const operations: Array = [ + ast.factory.createOperation({ + operationId: 'getPet', + method: 'GET', + path: '/pets/{pet_id}', + tags: ['pets'], + parameters: [ + ast.factory.createParameter({ name: 'pet_id', in: 'path', required: true, schema: ast.factory.createSchema({ type: 'integer' }) }), + ast.factory.createParameter({ + name: 'fields', + in: 'query', + required: false, + style: 'pipeDelimited', + explode: false, + schema: ast.factory.createSchema({ type: 'array', items: [ast.factory.createSchema({ type: 'string' })] }), + }), + ast.factory.createParameter({ name: 'session_id', in: 'cookie', required: false, schema: ast.factory.createSchema({ type: 'string' }) }), + ], + responses: [ + ast.factory.createResponse({ + statusCode: '200', + description: 'Pet response', + content: [ + ast.factory.createContent({ contentType: 'application/json', schema: ast.factory.createSchema({ type: 'object', properties: [] }) }), + ast.factory.createContent({ contentType: 'application/xml', schema: ast.factory.createSchema({ type: 'object', properties: [] }) }), + ast.factory.createContent({ contentType: 'application/pdf', schema: ast.factory.createSchema({ type: 'object', properties: [] }) }), + ], + }), + ], + }), + ast.factory.createOperation({ + operationId: 'listOrders', + method: 'GET', + path: '/orders', + tags: ['store'], + responses: [ast.factory.createResponse({ statusCode: '204', description: 'No content' })], + }), +] + +function adapterWithSecurity(): Adapter { + return { ...createMockedAdapter(), document: securityDocument } as Adapter +} + +async function render(options: PluginEffectHttpApiClient['resolvedOptions']): Promise> { + const plugin = createMockedPlugin({ + name: 'plugin-effect-httpapiclient', + options, + resolver: resolverEffectHttpApiClient, + }) + const driver = createMockedPluginDriver({ + name: options.mode, + plugin: mockedEffectPlugin as unknown as NonNullable[0]>['plugin'], + }) + + await renderGeneratorOperations(httpApiClientGenerator, operations, { + config: testConfig, + adapter: adapterWithSecurity(), + driver, + plugin, + options, + resolver: resolverEffectHttpApiClient, + }) + return rawSources(driver.fileManager.files) +} + +describe('httpApiClientGenerator', () => { + test('generates grouped native endpoints, content codecs, key remapping, and security', async () => { + const sources = await render(defaultOptions) + const output = sources.join('\n') + + expect(output).toContain('HttpApiEndpoint.get("getPet", "/pets/:pet_id"') + expect(output).toContain('Schema.encodeKeys({ "petId": "pet_id" })') + expect(output).toContain('Schema.optionalKey(queryParameter(GetPetQueryFields, {"name":"fields","kind":"array","style":"pipeDelimited","explode":false}))') + expect(output).toContain('headersWithCookies(') + expect(output).toContain('"cookies": Schema.optionalKey(Schema.Struct') + expect(output).toContain('export function pathParameter') + expect(output).toContain('HttpApiSchema.asJson({ contentType: "application/json" })') + expect(output).toContain('Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" }))') + expect(output).toContain('Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array({ contentType: "application/pdf" }))') + expect(output).toContain('HttpApiGroup.make("pets")') + expect(output).toContain('HttpApiClient.make(Api, { baseUrl: "https://example.com" })') + expect(output).toContain('"getPet": [[{"name":"apiKey","scopes":[]}]]') + }) + + test('marks groups as top-level in flat mode', async () => { + const sources = await render({ ...defaultOptions, mode: 'flat' }) + const api = sources.find((source) => source.includes('Root Effect HttpApi contract')) + + expect(api).toContain('HttpApiGroup.make("pets", { topLevel: true })') + expect(api).toContain('HttpApiGroup.make("store", { topLevel: true })') + }) +}) diff --git a/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx new file mode 100644 index 000000000..036c4b834 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx @@ -0,0 +1,627 @@ +import path from 'node:path' +import { caseParams, getOperationParameters, getStatusCodeNumber, operationFileEntry, resolveContentTypeVariants } from '@internals/shared' +import { camelCase } from '@internals/utils' +import { pluginEffectName, type PluginEffect, type ResolverEffect } from '@kubb/plugin-effect' +import { ast, defineGenerator } from 'kubb/kit' +import { File, jsxRenderer } from 'kubb/jsx' +import { renderCookieRuntime } from '../cookieRuntime.ts' +import { renderParameterRuntime } from '../parameterRuntime.ts' +import { resolveOperationSecurity, type ResolvedOperationSecurity, type ResolvedSecurityScheme, type SecurityDocument } from '../security.ts' +import { renderSecurityRuntime, type SecurityOperation } from '../securityRuntime.ts' +import type { PluginEffectHttpApiClient } from '../types.ts' + +type ContentNode = NonNullable[number] + +type OperationData = { + node: ast.HttpOperationNode + endpointName: string + endpointIdentifier: string + endpointFile: ast.FileNode + effectFile: ast.FileNode + effectImports: Array + usesCookieCodec: boolean + usesParameterCodec: boolean + source: string + security: ResolvedOperationSecurity | null +} + +const methodConstructors: Record = { + GET: 'get', + POST: 'post', + PUT: 'put', + PATCH: 'patch', + DELETE: 'delete', + HEAD: 'head', + OPTIONS: 'options', +} + +function quote(value: string): string { + return JSON.stringify(value) +} + +function unique(items: Array): Array { + return [...new Set(items)] +} + +function indent(value: string, spaces = 2): string { + const prefix = ' '.repeat(spaces) + return value + .split('\n') + .map((line) => `${prefix}${line}`) + .join('\n') +} + +function isJsonContentType(contentType: string): boolean { + const normalized = contentType.toLowerCase() + return normalized === 'application/json' || normalized.endsWith('+json') +} + +function isXmlContentType(contentType: string): boolean { + const normalized = contentType.toLowerCase() + return normalized === 'application/xml' || normalized === 'text/xml' || normalized.endsWith('+xml') +} + +function isTextContentType(contentType: string): boolean { + return contentType.toLowerCase().startsWith('text/') || isXmlContentType(contentType) +} + +function isStringSchema(schema: ast.SchemaNode | null | undefined): boolean { + return schema?.type === 'string' +} + +function payloadContentTypePriority(contentType: string): number { + const normalized = contentType.toLowerCase() + if (isJsonContentType(contentType)) return 0 + if (normalized === 'application/x-www-form-urlencoded') return 1 + if (normalized === 'multipart/form-data') return 2 + if (isTextContentType(contentType)) return 3 + if (normalized === 'application/octet-stream') return 4 + return 5 +} + +function contentSchemaNames({ + entries, + baseName, + useVariants, +}: { + entries: Array + baseName: string + useVariants: boolean +}): Map { + const names = new Map() + if (!useVariants) { + const entry = entries.find((item) => item.schema) + if (entry) names.set(entry, baseName) + return names + } + + const variants = resolveContentTypeVariants(entries, baseName) + let variantIndex = 0 + for (const entry of entries) { + if (!entry.schema) continue + const variant = variants[variantIndex++] + if (variant) names.set(entry, variant.name) + } + return names +} + +function annotatedContentSchema({ + contentType, + schema, + schemaName, + response, +}: { + contentType: string + schema: ast.SchemaNode | null | undefined + schemaName: string | undefined + response: boolean +}): { expression: string; importName: string | null } { + const normalized = contentType.toLowerCase() + if (response && normalized === 'text/event-stream') { + const data = schemaName ?? (schema ? 'Schema.Unknown' : 'Schema.String') + return { + expression: `HttpApiSchema.StreamSse({ data: ${data}, contentType: ${quote(contentType)} })`, + importName: schemaName ?? null, + } + } + if (isJsonContentType(contentType)) { + return { + expression: `${schemaName ?? 'Schema.Unknown'}.pipe(HttpApiSchema.asJson({ contentType: ${quote(contentType)} }))`, + importName: schemaName ?? null, + } + } + if (normalized === 'application/x-www-form-urlencoded') { + const value = schemaName ?? 'Schema.Record(Schema.String, Schema.String)' + return { + expression: `${value}.pipe(HttpApiSchema.asFormUrlEncoded({ contentType: ${quote(contentType)} }))`, + importName: schemaName ?? null, + } + } + if (!response && normalized === 'multipart/form-data') { + return { + expression: `${schemaName ?? 'Schema.Struct({})'}.pipe(HttpApiSchema.asMultipart())`, + importName: schemaName ?? null, + } + } + if (normalized === 'application/octet-stream' || !isTextContentType(contentType)) { + return { + expression: `Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array({ contentType: ${quote(contentType)} }))`, + importName: null, + } + } + if (isXmlContentType(contentType) || !schemaName || !isStringSchema(schema)) { + return { + expression: `Schema.String.pipe(HttpApiSchema.asText({ contentType: ${quote(contentType)} }))`, + importName: null, + } + } + return { + expression: `${schemaName}.pipe(HttpApiSchema.asText({ contentType: ${quote(contentType)} }))`, + importName: schemaName, + } +} + +type ParameterLocation = 'path' | 'query' | 'header' + +function parameterKind(schema: ast.SchemaNode): 'primitive' | 'array' | 'object' { + const resolved = schema.type === 'ref' ? schema.schema : schema + if (resolved?.type === 'array' || resolved?.type === 'tuple') return 'array' + if (resolved?.type === 'object') return 'object' + return 'primitive' +} + +function parameterOptions({ param, location }: { param: ast.ParameterNode; location: ParameterLocation }): string { + const defaults = { + path: { style: 'simple', explode: false }, + query: { style: 'form', explode: true }, + header: { style: 'simple', explode: false }, + } as const + const fallback = defaults[location] + return JSON.stringify({ + name: param.name, + kind: parameterKind(param.schema), + style: param.style ?? fallback.style, + explode: param.explode ?? fallback.explode, + }) +} + +function renderParameterStruct({ + node, + params, + location, + effectResolver, +}: { + node: ast.HttpOperationNode + params: Array + location: ParameterLocation + effectResolver: ResolverEffect +}): { + expression: string | null + imports: Array +} { + if (!params.length) return { expression: null, imports: [] } + const cased = caseParams(params, 'camelcase') + const codec = `${location}Parameter` + const fields = cased.map((param, index) => { + const original = params[index]! + const schema = `${codec}(${effectResolver.param.name(node, param)}, ${parameterOptions({ param: original, location })})` + return `${quote(param.name)}: ${original.required ? schema : `Schema.optionalKey(${schema})`}` + }) + const mapping = cased.flatMap((param, index) => { + const original = params[index] + if (!original || original.name === param.name) return [] + return [`${quote(param.name)}: ${quote(original.name)}`] + }) + const struct = `Schema.Struct({\n${indent(fields.join(',\n'), 2)},\n})` + return { + expression: mapping.length ? `${struct}.pipe(Schema.encodeKeys({ ${mapping.join(', ')} }))` : struct, + imports: cased.map((param) => effectResolver.param.name(node, param)), + } +} + +function renderHeadersStruct({ + node, + headers, + cookies, + effectResolver, +}: { + node: ast.HttpOperationNode + headers: Array + cookies: Array + effectResolver: ResolverEffect +}): { expression: string | null; imports: Array; usesCookieCodec: boolean } { + if (!cookies.length) return { ...renderParameterStruct({ node, params: headers, location: 'header', effectResolver }), usesCookieCodec: false } + + const casedHeaders = caseParams(headers, 'camelcase') + const casedCookies = caseParams(cookies, 'camelcase') + if (casedHeaders.some((header) => header.name === 'cookies')) { + throw new Error(`Operation "${node.operationId}" has a header parameter that conflicts with the generated headers.cookies field`) + } + + const headerFields = casedHeaders.map((param, index) => { + const schema = effectResolver.param.name(node, param) + return `${quote(param.name)}: ${headers[index]?.required ? schema : `Schema.optionalKey(${schema})`}` + }) + const cookieFields = casedCookies.map((param, index) => { + const schema = effectResolver.param.name(node, param) + return `${quote(param.name)}: ${cookies[index]?.required ? schema : `Schema.optionalKey(${schema})`}` + }) + const cookieMapping = casedCookies.flatMap((param, index) => { + const original = cookies[index] + if (!original || original.name === param.name) return [] + return [`${quote(param.name)}: ${quote(original.name)}`] + }) + let cookieStruct = `Schema.Struct({\n${indent(cookieFields.join(',\n'), 4)},\n })` + if (cookieMapping.length) cookieStruct += `.pipe(Schema.encodeKeys({ ${cookieMapping.join(', ')} }))` + const cookiesField = cookies.some((param) => param.required) ? cookieStruct : `Schema.optionalKey(${cookieStruct})` + const fields = [...headerFields, `${quote('cookies')}: ${cookiesField}`] + let struct = `Schema.Struct({\n${indent(fields.join(',\n'), 2)},\n})` + const headerMapping = casedHeaders.flatMap((param, index) => { + const original = headers[index] + if (!original || original.name === param.name) return [] + return [`${quote(param.name)}: ${quote(original.name)}`] + }) + if (headerMapping.length) struct += `.pipe(Schema.encodeKeys({ ${headerMapping.join(', ')} }))` + const parameters = [ + ...headers.map((param) => ({ name: param.name, location: 'header', explode: param.explode ?? false })), + ...cookies.map((param) => ({ name: param.name, location: 'cookie', explode: param.explode ?? true })), + ] + + return { + expression: `headersWithCookies(${struct}, ${JSON.stringify(parameters)})`, + imports: [...casedHeaders, ...casedCookies].map((param) => effectResolver.param.name(node, param)), + usesCookieCodec: true, + } +} + +function renderResponses({ node, effectResolver }: { node: ast.HttpOperationNode; effectResolver: ResolverEffect }): { + success: Array + error: Array + imports: Array +} { + const success: Array = [] + const error: Array = [] + const imports: Array = [] + + for (const response of node.responses) { + const status = getStatusCodeNumber(response.statusCode) + if (status === null) throw new Error(`Operation "${node.operationId}" uses unsupported non-numeric response status "${response.statusCode}"`) + const target = status >= 200 && status < 300 ? success : error + const entries = response.content ?? [] + if (!entries.length) { + target.push(`HttpApiSchema.Empty(${status})`) + continue + } + + const entriesWithSchema = entries.filter((entry) => entry.schema) + const baseName = effectResolver.response.status(node, response.statusCode) + const names = contentSchemaNames({ entries, baseName, useVariants: entriesWithSchema.length > 1 }) + for (const entry of entries) { + const content = annotatedContentSchema({ + contentType: entry.contentType, + schema: entry.schema, + schemaName: names.get(entry), + response: true, + }) + if (content.importName) imports.push(content.importName) + target.push(`${content.expression}.pipe(HttpApiSchema.status(${status}))`) + } + } + + return { success, error, imports: unique(imports) } +} + +function renderPayload({ node, effectResolver }: { node: ast.HttpOperationNode; effectResolver: ResolverEffect }): { + expression: string | null + imports: Array +} { + const entries = node.requestBody?.content ?? [] + if (!entries.length) return { expression: null, imports: [] } + + const baseName = effectResolver.response.body(node) + const names = contentSchemaNames({ entries, baseName, useVariants: entries.length > 1 }) + const imports: Array = [] + const orderedEntries = [...entries].sort((left, right) => payloadContentTypePriority(left.contentType) - payloadContentTypePriority(right.contentType)) + const expressions = orderedEntries.map((entry) => { + const content = annotatedContentSchema({ + contentType: entry.contentType, + schema: entry.schema, + schemaName: names.get(entry), + response: false, + }) + if (content.importName) imports.push(content.importName) + return content.expression + }) + return { + expression: expressions.length === 1 ? expressions[0]! : `[${expressions.join(', ')}]`, + imports: unique(imports), + } +} + +function renderEndpointAnnotations({ node, security }: { node: ast.HttpOperationNode; security: ResolvedOperationSecurity | null }): string | null { + const fields: Array = [] + if (node.summary) fields.push(`summary: ${quote(node.summary)}`) + if (node.description) fields.push(`description: ${quote(node.description)}`) + if (node.deprecated !== undefined) fields.push(`deprecated: ${node.deprecated}`) + if (security) { + const requirements = security.requirements.map((alternative) => Object.fromEntries(alternative.map((entry) => [entry.name, entry.scopes]))) + fields.push(`override: { security: ${JSON.stringify(requirements)} }`) + } + return fields.length ? `OpenApi.annotations({ ${fields.join(', ')} })` : null +} + +function renderEndpointSource({ + node, + endpointName, + endpointIdentifier, + effectResolver, + security, +}: { + node: ast.HttpOperationNode + endpointName: string + endpointIdentifier: string + effectResolver: ResolverEffect + security: ResolvedOperationSecurity | null +}): { source: string; imports: Array; usesCookieCodec: boolean; usesParameterCodec: boolean } { + const method = methodConstructors[node.method.toUpperCase()] + if (!method) throw new Error(`Operation "${node.operationId}" uses unsupported HTTP method "${node.method.toUpperCase()}"`) + + const parameters = getOperationParameters(node, { paramsCasing: 'original' }) + const params = renderParameterStruct({ node, params: parameters.path, location: 'path', effectResolver }) + const query = renderParameterStruct({ node, params: parameters.query, location: 'query', effectResolver }) + const headers = renderHeadersStruct({ node, headers: parameters.header, cookies: parameters.cookie, effectResolver }) + const payload = renderPayload({ node, effectResolver }) + const responses = renderResponses({ node, effectResolver }) + const options: Array = [] + if (params.expression) options.push(`params: ${params.expression}`) + if (query.expression) options.push(`query: ${query.expression}`) + if (headers.expression) options.push(`headers: ${headers.expression}`) + if (payload.expression) options.push(`payload: ${payload.expression}`) + if (responses.success.length) options.push(`success: [${responses.success.join(', ')}]`) + if (responses.error.length) options.push(`error: [${responses.error.join(', ')}]`) + + const path = node.path.replaceAll(/\{([^}]+)\}/g, ':$1') + let expression = `HttpApiEndpoint.${method}(${quote(endpointIdentifier)}, ${quote(path)}, {\n${indent(options.join(',\n'), 2)},\n})` + if (security) expression += '.middleware(ApiSecurity)' + const annotations = renderEndpointAnnotations({ node, security }) + if (annotations) expression += `.annotateMerge(${annotations})` + + return { + source: `/**\n * ${node.summary ?? `HttpApi endpoint for ${node.method.toUpperCase()} ${node.path}.`}\n */\nexport const ${endpointName} = ${expression}\n`, + imports: unique([...params.imports, ...query.imports, ...headers.imports, ...payload.imports, ...responses.imports]), + usesCookieCodec: headers.usesCookieCodec, + usesParameterCodec: parameters.path.length > 0 || parameters.query.length > 0 || (parameters.header.length > 0 && parameters.cookie.length === 0), + } +} + +function renderApiSource({ + operations, + mode, + apiName, + groupName, + groupIdentifier, + title, + version, + description, +}: { + operations: Array + mode: 'tag' | 'flat' + apiName: string + groupName(tag: string): string + groupIdentifier(tag: string): string + title: string | undefined + version: string | undefined + description: string | undefined +}): string { + const grouped = new Map>() + for (const operation of operations) { + const tag = operation.node.tags[0] ?? 'default' + const existing = grouped.get(tag) + if (existing) existing.push(operation) + else grouped.set(tag, [operation]) + } + + const groups = [...grouped.entries()].map(([tag, entries]) => { + const name = groupName(tag) + const identifier = groupIdentifier(tag) + const options = mode === 'flat' ? ', { topLevel: true }' : '' + const additions = entries.map((entry) => `.add(${entry.endpointName})`).join('') + return `/**\n * HttpApi group for the ${tag} operations.\n */\nexport const ${name} = HttpApiGroup.make(${quote(identifier)}${options})${additions}` + }) + const apiAdditions = [...grouped.keys()].map((tag) => `.add(${groupName(tag)})`).join('') + const annotationFields = [ + title ? `title: ${quote(title)}` : null, + version ? `version: ${quote(version)}` : null, + description ? `description: ${quote(description)}` : null, + ].filter((field): field is string => Boolean(field)) + const annotations = annotationFields.length ? `.annotateMerge(OpenApi.annotations({ ${annotationFields.join(', ')} }))` : '' + return `${groups.join('\n\n')}\n\n/**\n * Root Effect HttpApi contract.\n */\nexport const ${apiName} = HttpApi.make(${quote(camelCase(title ?? 'api'))})${apiAdditions}${annotations}\n` +} + +function renderClientSource({ apiName, clientName, baseURL }: { apiName: string; clientName: string; baseURL: string | undefined }): string { + const options = baseURL ? `, { baseUrl: ${quote(baseURL)} }` : '' + return `/**\n * Client methods derived from the generated HttpApi contract.\n */\nexport type ${clientName} = HttpApiClient.ForApi\n\n/**\n * Effect that constructs the generated HttpApi client.\n */\nexport const ${clientName} = HttpApiClient.make(${apiName}${options})\n` +} + +function relativeModulePath({ from, to }: { from: string; to: string }): string { + const relative = path + .relative(path.dirname(from), to) + .replaceAll(path.sep, '/') + .replace(/\.[^.]+$/, '') + return relative.startsWith('.') ? relative : `./${relative}` +} + +/** + * Generates Effect HttpApi endpoints, groups, the root API, the fixed client Effect, and security middleware. + */ +export const httpApiClientGenerator = defineGenerator({ + name: 'effect-httpapiclient', + renderer: jsxRenderer, + operations(nodes, ctx) { + const { config, driver, resolver, root } = ctx + const { output, group, mode, baseURL } = ctx.options + const pluginEffect = driver.getPlugin(pluginEffectName) + if (!pluginEffect) return null + + const effectResolver = driver.getResolver(pluginEffectName) + const effectOptions = pluginEffect.options as PluginEffect['resolvedOptions'] | undefined + const effectOutput = effectOptions?.output ?? output + const effectGroup = effectOptions?.group ?? undefined + const securityDocument = ctx.adapter.document as SecurityDocument | null | undefined + const securityFile = resolver.file({ name: 'security', extname: '.ts', root, output, group: group ?? undefined }) + const cookieFile = resolver.file({ name: 'cookieParameters', extname: '.ts', root, output, group: group ?? undefined }) + const parameterFile = resolver.file({ name: 'parameterSerialization', extname: '.ts', root, output, group: group ?? undefined }) + + const operations = nodes.filter(ast.isHttpOperationNode).map((node): OperationData => { + const endpointName = resolver.endpoint.name(node) + const endpointIdentifier = resolver.endpoint.identifier(node) + const endpointFile = resolver.file({ + ...operationFileEntry(node, `${node.operationId}Endpoint`), + root, + output, + group: group ?? undefined, + }) + const effectFile = effectResolver.file({ + ...operationFileEntry(node, node.operationId), + root, + output: effectOutput, + group: effectGroup, + }) + const security = resolveOperationSecurity({ document: securityDocument, method: node.method, path: node.path }) + const rendered = renderEndpointSource({ node, endpointName, endpointIdentifier, effectResolver, security }) + return { + node, + endpointName, + endpointIdentifier, + endpointFile, + effectFile, + effectImports: rendered.imports, + usesCookieCodec: rendered.usesCookieCodec, + usesParameterCodec: rendered.usesParameterCodec, + source: rendered.source, + security, + } + }) + + const endpointIdentifiers = new Set() + for (const operation of operations) { + if (endpointIdentifiers.has(operation.endpointIdentifier)) { + throw new Error(`Duplicate Effect HttpApi endpoint identifier "${operation.endpointIdentifier}"`) + } + endpointIdentifiers.add(operation.endpointIdentifier) + } + + const apiFile = resolver.file({ name: 'api', extname: '.ts', root, output, group: group ?? undefined }) + const clientFile = resolver.file({ name: 'apiClient', extname: '.ts', root, output, group: group ?? undefined }) + const indexFile = resolver.file({ name: 'index', extname: '.ts', root, output, group: group ?? undefined }) + const apiName = resolver.api.name() + const clientName = resolver.client.name() + const securityOperations: Array = operations.flatMap((operation) => + operation.security ? [{ identifier: operation.endpointIdentifier, requirements: operation.security.requirements }] : [], + ) + const schemes = new Map() + for (const operation of operations) { + for (const scheme of operation.security?.schemes ?? []) schemes.set(scheme.name, scheme) + } + + const banner = (file: ast.FileNode) => resolver.default.banner(ctx.meta, { output, config, file: { path: file.path, baseName: file.baseName } }) + const footer = (file: ast.FileNode) => resolver.default.footer(ctx.meta, { output, config, file: { path: file.path, baseName: file.baseName } }) + + return ( + <> + {operations.map((operation) => ( + + {/(^|\W)Schema\./.test(operation.source) && } + + {operation.effectImports.length > 0 && ( + + )} + {operation.usesCookieCodec && } + {operation.usesParameterCodec && ( + + )} + {operation.security && } + {operation.source} + + ))} + + + + {operations.map((operation) => ( + + ))} + + {renderApiSource({ + operations, + mode, + apiName, + groupName: resolver.group.name.bind(resolver.group), + groupIdentifier: resolver.group.identifier.bind(resolver.group), + title: ctx.meta.title, + version: ctx.meta.version, + description: ctx.meta.description, + })} + + + + + + + {renderClientSource({ apiName, clientName, baseURL })} + + + {securityOperations.length > 0 && ( + + + + + {renderSecurityRuntime({ operations: securityOperations, schemes: [...schemes.values()] })} + + )} + + {operations.some((operation) => operation.usesCookieCodec) && ( + + + + + {renderCookieRuntime()} + + )} + + {operations.some((operation) => operation.usesParameterCodec) && ( + + + + {renderParameterRuntime()} + + )} + + {indexFile.path !== apiFile.path && ( + + + {[ + `export * from ${quote(relativeModulePath({ from: indexFile.path, to: apiFile.path }))}`, + `export * from ${quote(relativeModulePath({ from: indexFile.path, to: clientFile.path }))}`, + ...operations.map((operation) => `export * from ${quote(relativeModulePath({ from: indexFile.path, to: operation.endpointFile.path }))}`), + ...(securityOperations.length ? [`export * from ${quote(relativeModulePath({ from: indexFile.path, to: securityFile.path }))}`] : []), + ].join('\n')} + + + )} + + ) + }, +}) diff --git a/packages/plugin-effect-httpapiclient/src/index.ts b/packages/plugin-effect-httpapiclient/src/index.ts new file mode 100644 index 000000000..a11df5214 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/index.ts @@ -0,0 +1,4 @@ +export { httpApiClientGenerator } from './generators/httpApiClientGenerator.tsx' +export { default, pluginEffectHttpApiClient, pluginEffectHttpApiClientName } from './plugin.ts' +export { resolverEffectHttpApiClient } from './resolvers/resolverEffectHttpApiClient.ts' +export type { Options, PluginEffectHttpApiClient, ResolvedOptions, ResolverEffectHttpApiClient } from './types.ts' diff --git a/packages/plugin-effect-httpapiclient/src/parameterRuntime.ts b/packages/plugin-effect-httpapiclient/src/parameterRuntime.ts new file mode 100644 index 000000000..b415b0af6 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/parameterRuntime.ts @@ -0,0 +1,156 @@ +/** + * Renders codecs that apply OpenAPI parameter serialization before Effect sends a request. + */ +export function renderParameterRuntime(): string { + return ` +type ParameterKind = 'primitive' | 'array' | 'object' + +type ParameterOptions = { + readonly name: string + readonly kind: ParameterKind + readonly style: 'matrix' | 'label' | 'form' | 'simple' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject' + readonly explode: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function valueToString(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +function serializePath({ value, options }: { value: unknown; options: ParameterOptions }): string { + const { explode, name, style } = options + if (Array.isArray(value)) { + const values = value.map(valueToString) + if (style === 'label') return '.' + values.join(explode ? '.' : ',') + if (style === 'matrix') return explode ? values.map((item) => ';' + name + '=' + item).join('') : ';' + name + '=' + values.join(',') + return values.join(',') + } + if (isRecord(value)) { + const entries = Object.entries(value) + const members = entries.map(([key, item]) => (explode ? key + '=' + valueToString(item) : key + ',' + valueToString(item))) + if (style === 'label') return '.' + members.join(explode ? '.' : ',') + if (style === 'matrix') return explode ? members.map((member) => ';' + member).join('') : ';' + name + '=' + members.join(',') + return members.join(',') + } + const serialized = valueToString(value) + if (style === 'label') return '.' + serialized + if (style === 'matrix') return ';' + name + '=' + serialized + return serialized +} + +function serializeQuery({ value, options }: { value: unknown; options: ParameterOptions }): string | Array | Record { + if (Array.isArray(value)) { + const values = value.map(valueToString) + if (options.explode) return values + const delimiter = options.style === 'spaceDelimited' ? ' ' : options.style === 'pipeDelimited' ? '|' : ',' + return values.join(delimiter) + } + if (isRecord(value)) { + if (options.style === 'deepObject' || options.explode) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, valueToString(item)])) + return Object.entries(value) + .flatMap(([key, item]) => [key, valueToString(item)]) + .join(',') + } + return valueToString(value) +} + +function serializeHeader({ value, explode }: { value: unknown; explode: boolean }): string { + if (Array.isArray(value)) return value.map(valueToString).join(',') + if (!isRecord(value)) return valueToString(value) + const entries = Object.entries(value) + if (explode) return entries.map(([key, item]) => key + '=' + valueToString(item)).join(',') + return entries.flatMap(([key, item]) => [key, valueToString(item)]).join(',') +} + +function deserialize({ value, options }: { value: unknown; options: ParameterOptions }): unknown { + if (options.kind === 'primitive' || typeof value !== 'string') return value + let serialized = value + if (options.style === 'label') serialized = serialized.slice(1) + if (options.style === 'matrix') { + serialized = serialized.startsWith(';') ? serialized.slice(1) : serialized + if (!options.explode && serialized.startsWith(options.name + '=')) serialized = serialized.slice(options.name.length + 1) + } + if (options.kind === 'array') { + const delimiter = options.style === 'spaceDelimited' ? ' ' : options.style === 'pipeDelimited' ? '|' : options.style === 'label' && options.explode ? '.' : ',' + return serialized.split(delimiter).map((item) => { + if (options.style === 'matrix' && options.explode && item.startsWith(options.name + '=')) return item.slice(options.name.length + 1) + return item + }) + } + const delimiter = options.style === 'label' && options.explode ? '.' : options.style === 'matrix' && options.explode ? ';' : ',' + const parts = serialized.split(delimiter) + const entries: Array<[string, string]> = [] + if (options.explode) { + for (const part of parts) { + const separator = part.indexOf('=') + if (separator !== -1) entries.push([part.slice(0, separator), part.slice(separator + 1)]) + } + } else { + for (let index = 0; index < parts.length; index += 2) { + const key = parts[index] + const item = parts[index + 1] + if (key !== undefined && item !== undefined) entries.push([key, item]) + } + } + return Object.fromEntries(entries) +} + +function invalidParameter(value: unknown) { + return new SchemaIssue.InvalidValue(Option.some(value), { message: 'Could not serialize the OpenAPI parameter' }) +} + +function parameterCodec({ + encoded, + schema, + options, + serialize, +}: { + encoded: E + schema: S + options: ParameterOptions + serialize(value: S['Encoded']): E['Type'] +}) { + return Schema.decodeTo(schema, { + decode: SchemaGetter.transform((value) => deserialize({ value, options }) as S['Encoded']), + encode: SchemaGetter.transformOrFail((value) => { + try { + return Effect.succeed(serialize(value)) + } catch { + return Effect.fail(invalidParameter(value)) + } + }), + })(encoded) +} + +/** + * Applies OpenAPI simple, label, or matrix serialization to a path parameter. + */ +export function pathParameter(schema: S, options: ParameterOptions) { + return parameterCodec({ encoded: Schema.String, schema, options, serialize: (value) => serializePath({ value, options }) }) +} + +/** + * Applies OpenAPI form, delimited, or deep-object serialization to a query parameter. + */ +export function queryParameter(schema: S, options: ParameterOptions) { + const encoded = Schema.Union([ + Schema.String, + Schema.Array(Schema.String), + Schema.Record(Schema.String, Schema.String), + ]) + return parameterCodec({ encoded, schema, options, serialize: (value) => serializeQuery({ value, options }) }) +} + +/** + * Applies OpenAPI simple serialization to a header parameter. + */ +export function headerParameter(schema: S, options: ParameterOptions) { + return parameterCodec({ encoded: Schema.String, schema, options, serialize: (value) => serializeHeader({ value, explode: options.explode }) }) +} +` +} diff --git a/packages/plugin-effect-httpapiclient/src/plugin.ts b/packages/plugin-effect-httpapiclient/src/plugin.ts new file mode 100644 index 000000000..54c3bbc50 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/plugin.ts @@ -0,0 +1,67 @@ +import { createGroupConfig } from '@internals/shared' +import { pluginEffectName } from '@kubb/plugin-effect' +import { definePlugin, Resolver } from 'kubb/kit' +import { httpApiClientGenerator } from './generators/httpApiClientGenerator.tsx' +import { resolverEffectHttpApiClient } from './resolvers/resolverEffectHttpApiClient.ts' +import type { PluginEffectHttpApiClient } from './types.ts' + +/** + * Canonical plugin name for `@kubb/plugin-effect-httpapiclient`. + */ +export const pluginEffectHttpApiClientName = 'plugin-effect-httpapiclient' satisfies PluginEffectHttpApiClient['name'] + +/** + * Generates Effect v4 HttpApi contracts and a fixed HttpApiClient Effect from OpenAPI. + * + * @example + * ```ts + * import { pluginEffect } from '@kubb/plugin-effect' + * import { pluginEffectHttpApiClient } from '@kubb/plugin-effect-httpapiclient' + * import { defineConfig } from 'kubb/config' + * + * export default defineConfig({ + * input: './petStore.yaml', + * output: { path: './src/gen' }, + * plugins: [pluginEffect(), pluginEffectHttpApiClient({ baseURL: 'https://petstore.example.com' })], + * }) + * ``` + * + * @beta + */ +export const pluginEffectHttpApiClient = definePlugin((options) => { + const { + output = { path: 'effectHttpApiClient', barrel: { type: 'named' } }, + group, + baseURL, + mode = 'tag', + exclude = [], + include, + override = [], + resolver: userResolver, + macros, + } = options + + return { + name: pluginEffectHttpApiClientName, + options, + dependencies: [pluginEffectName], + hooks: { + 'kubb:plugin:setup'(ctx) { + ctx.setOptions({ + output, + exclude, + include, + override, + group: createGroupConfig(group), + baseURL, + mode, + }) + ctx.setResolver(userResolver ? Resolver.merge(resolverEffectHttpApiClient, userResolver) : resolverEffectHttpApiClient) + if (macros?.length) ctx.setMacros(macros) + ctx.addGenerator(httpApiClientGenerator) + }, + }, + } +}) + +export default pluginEffectHttpApiClient diff --git a/packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.test.ts b/packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.test.ts new file mode 100644 index 000000000..2ecb8ed4f --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.test.ts @@ -0,0 +1,16 @@ +import { ast } from 'kubb/kit' +import { describe, expect, test } from 'vitest' +import { resolverEffectHttpApiClient } from './resolverEffectHttpApiClient.ts' + +describe('resolverEffectHttpApiClient', () => { + test('resolves endpoint, group, API, and client names', () => { + const operation = ast.factory.createOperation({ operationId: 'get pet by id', method: 'GET', path: '/pets/{id}', responses: [] }) + + expect(resolverEffectHttpApiClient.endpoint.name(operation)).toBe('getPetByIdEndpoint') + expect(resolverEffectHttpApiClient.endpoint.identifier(operation)).toBe('getPetById') + expect(resolverEffectHttpApiClient.group.name('pet store')).toBe('PetStoreGroup') + expect(resolverEffectHttpApiClient.group.identifier('pet store')).toBe('petStore') + expect(resolverEffectHttpApiClient.api.name()).toBe('Api') + expect(resolverEffectHttpApiClient.client.name()).toBe('ApiClient') + }) +}) diff --git a/packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.ts b/packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.ts new file mode 100644 index 000000000..a7bb216bc --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/resolvers/resolverEffectHttpApiClient.ts @@ -0,0 +1,41 @@ +import { createCasedFile } from '@internals/shared' +import { camelCase, ensureValidVarName, pascalCase } from '@internals/utils' +import { createResolver } from 'kubb/kit' +import type { PluginEffectHttpApiClient } from '../types.ts' + +/** + * Default resolver for Effect HttpApi endpoint, group, API, and client names. + */ +export const resolverEffectHttpApiClient = createResolver({ + pluginName: 'plugin-effect-httpapiclient', + name(name) { + return ensureValidVarName(camelCase(name)) + }, + file: createCasedFile(camelCase), + endpoint: { + name(node) { + return ensureValidVarName(camelCase(node.operationId, { suffix: 'endpoint' })) + }, + identifier(node) { + return ensureValidVarName(camelCase(node.operationId)) + }, + }, + group: { + name(tag) { + return ensureValidVarName(pascalCase(tag, { suffix: 'group' })) + }, + identifier(tag) { + return ensureValidVarName(camelCase(tag)) + }, + }, + api: { + name() { + return 'Api' + }, + }, + client: { + name() { + return 'ApiClient' + }, + }, +}) diff --git a/packages/plugin-effect-httpapiclient/src/security.test.ts b/packages/plugin-effect-httpapiclient/src/security.test.ts new file mode 100644 index 000000000..4f83cdf82 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/security.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest' +import { resolveOperationSecurity, type SecurityDocument } from './security.ts' + +const document: SecurityDocument = { + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer' }, + oauth: { type: 'oauth2' }, + apiKey: { type: 'apiKey', name: 'x-api-key', in: 'header' }, + }, + }, + paths: { + '/pets': { + get: { security: [{ oauth: ['read:pets'], apiKey: [] }, {}] }, + post: { security: [] }, + }, + }, +} + +describe('resolveOperationSecurity', () => { + test('preserves ordered OR alternatives, AND entries, and scopes', () => { + expect(resolveOperationSecurity({ document, method: 'GET', path: '/pets' })).toStrictEqual({ + requirements: [ + [ + { name: 'oauth', scopes: ['read:pets'] }, + { name: 'apiKey', scopes: [] }, + ], + [], + ], + schemes: [ + { name: 'oauth', credential: 'bearer' }, + { name: 'apiKey', credential: 'apiKey', in: 'header', wireName: 'x-api-key' }, + ], + }) + }) + + test('uses global security when an operation has no override', () => { + expect(resolveOperationSecurity({ document, method: 'DELETE', path: '/pets' })?.requirements).toStrictEqual([[{ name: 'bearerAuth', scopes: [] }]]) + }) + + test('disables inherited security for an explicit empty operation array', () => { + expect(resolveOperationSecurity({ document, method: 'POST', path: '/pets' })).toBeNull() + }) + + test('rejects AND requirements that overwrite the same request slot', () => { + const conflicting: SecurityDocument = { + components: { + securitySchemes: { + first: { type: 'http', scheme: 'bearer' }, + second: { type: 'oauth2' }, + }, + }, + security: [{ first: [], second: [] }], + } + + expect(() => resolveOperationSecurity({ document: conflicting, method: 'GET', path: '/pets' })).toThrow('both write header:authorization') + }) +}) diff --git a/packages/plugin-effect-httpapiclient/src/security.ts b/packages/plugin-effect-httpapiclient/src/security.ts new file mode 100644 index 000000000..11904ed0c --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/security.ts @@ -0,0 +1,176 @@ +/** + * Minimal OpenAPI document shape used to resolve security requirements. + */ +export type SecurityDocument = { + /** + * Security requirements inherited by operations without an override. + */ + security?: Array>> + /** + * Reusable OpenAPI components. + */ + components?: { + /** + * Named security scheme definitions. + */ + securitySchemes?: Record + } + /** + * Operations indexed by their OpenAPI path and method. + */ + paths?: Record>> } | undefined> | undefined> +} + +type SecuritySchemeReference = { + $ref: string +} + +type SecuritySchemeObject = + | { + type: 'http' + scheme?: string + } + | { + type: 'apiKey' + name?: string + in?: string + } + | { + type: 'oauth2' + } + | { + type: 'openIdConnect' + } + +/** + * Security scheme normalized for generated client credential injection. + */ +export type ResolvedSecurityScheme = { + /** + * Scheme name used by OpenAPI security requirements. + */ + name: string + /** + * Credential shape expected by the generated security layer. + */ + credential: 'apiKey' | 'basic' | 'bearer' | 'authorization' + /** + * Request location for an API key. + */ + in?: 'header' | 'query' | 'cookie' + /** + * Wire name for an API key. + */ + wireName?: string +} + +/** + * One scheme entry inside an OpenAPI security requirement. + */ +export type ResolvedSecurityRequirementEntry = { + /** + * Referenced security scheme name. + */ + name: string + /** + * OAuth2 or OpenID Connect scopes required by the operation. + */ + scopes: Array +} + +/** + * Effective security requirements and schemes for one operation. + */ +export type ResolvedOperationSecurity = { + /** + * Ordered OR alternatives whose entries are combined with AND semantics. + */ + requirements: Array> + /** + * Schemes referenced by the alternatives. + */ + schemes: Array +} + +function resolveSchemeObject({ document, name }: { document: SecurityDocument; name: string }): SecuritySchemeObject { + const scheme = document.components?.securitySchemes?.[name] + if (!scheme) throw new Error(`Security scheme "${name}" is not defined in components.securitySchemes`) + if (!('$ref' in scheme)) return scheme + + const prefix = '#/components/securitySchemes/' + if (!scheme.$ref.startsWith(prefix)) throw new Error(`Security scheme "${name}" uses unsupported reference "${scheme.$ref}"`) + const referencedName = decodeURIComponent(scheme.$ref.slice(prefix.length)) + const referenced = document.components?.securitySchemes?.[referencedName] + if (!referenced || '$ref' in referenced) throw new Error(`Security scheme "${name}" has an unresolved reference "${scheme.$ref}"`) + return referenced +} + +function resolveScheme({ document, name }: { document: SecurityDocument; name: string }): ResolvedSecurityScheme { + const scheme = resolveSchemeObject({ document, name }) + if (scheme.type === 'oauth2' || scheme.type === 'openIdConnect') return { name, credential: 'bearer' } + if (scheme.type === 'http') { + const httpScheme = scheme.scheme?.toLowerCase() + if (httpScheme === 'basic') return { name, credential: 'basic' } + if (httpScheme === 'bearer') return { name, credential: 'bearer' } + return { name, credential: 'authorization' } + } + + if (!scheme.name) throw new Error(`API key security scheme "${name}" is missing its wire name`) + if (scheme.in !== 'header' && scheme.in !== 'query' && scheme.in !== 'cookie') { + throw new Error(`API key security scheme "${name}" has unsupported location "${scheme.in ?? ''}"`) + } + return { name, credential: 'apiKey', in: scheme.in, wireName: scheme.name } +} + +function credentialSlot(scheme: ResolvedSecurityScheme): string { + if (scheme.credential !== 'apiKey') return 'header:authorization' + return `${scheme.in}:${scheme.wireName?.toLowerCase()}` +} + +function assertNoRequirementConflicts({ + requirement, + schemes, +}: { + requirement: Array + schemes: Map +}) { + const slots = new Map() + for (const entry of requirement) { + const scheme = schemes.get(entry.name) + if (!scheme) continue + const slot = credentialSlot(scheme) + const previous = slots.get(slot) + if (previous) throw new Error(`Security requirement cannot combine "${previous}" and "${entry.name}" because both write ${slot}`) + slots.set(slot, entry.name) + } +} + +/** + * Resolves OpenAPI global and operation security without flattening OR, AND, or scope information. + */ +export function resolveOperationSecurity({ + document, + method, + path, +}: { + document: SecurityDocument | null | undefined + method: string + path: string +}): ResolvedOperationSecurity | null { + if (!document) return null + + const operation = document.paths?.[path]?.[method.toLowerCase()] + const requirements = operation && Object.hasOwn(operation, 'security') ? operation.security : document.security + if (!requirements?.length) return null + + const resolvedRequirements = requirements.map((requirement) => Object.entries(requirement).map(([name, scopes]) => ({ name, scopes: [...scopes] }))) + const schemes = new Map() + for (const requirement of resolvedRequirements) { + for (const entry of requirement) { + if (!schemes.has(entry.name)) schemes.set(entry.name, resolveScheme({ document, name: entry.name })) + } + assertNoRequirementConflicts({ requirement, schemes }) + } + + return { requirements: resolvedRequirements, schemes: [...schemes.values()] } +} diff --git a/packages/plugin-effect-httpapiclient/src/securityRuntime.ts b/packages/plugin-effect-httpapiclient/src/securityRuntime.ts new file mode 100644 index 000000000..7765d4209 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/securityRuntime.ts @@ -0,0 +1,269 @@ +import type { ResolvedSecurityScheme, ResolvedSecurityRequirementEntry } from './security.ts' + +/** + * Security metadata embedded in the generated client middleware. + */ +export type SecurityOperation = { + /** + * Generated HttpApi endpoint identifier. + */ + identifier: string + /** + * Ordered OpenAPI security alternatives. + */ + requirements: Array> +} + +function credentialType(scheme: ResolvedSecurityScheme): string { + if (scheme.credential === 'basic') return 'BasicCredential' + if (scheme.credential === 'bearer') return 'BearerCredential' + if (scheme.credential === 'authorization') return 'AuthorizationCredential' + return 'ApiKeyCredential' +} + +/** + * Renders the generated client-only security middleware and credential Layer. + */ +export function renderSecurityRuntime({ operations, schemes }: { operations: Array; schemes: Array }): string { + const schemeNames = schemes.map((scheme) => JSON.stringify(scheme.name)).join(' | ') || 'never' + const credentialFields = schemes.map((scheme) => ` readonly ${JSON.stringify(scheme.name)}?: ${credentialType(scheme)}`).join('\n') + const schemeEntries = schemes + .map( + (scheme) => + ` ${JSON.stringify(scheme.name)}: ${JSON.stringify({ + credential: scheme.credential, + in: scheme.in, + wireName: scheme.wireName, + })},`, + ) + .join('\n') + const requirementEntries = operations.map((operation) => ` ${JSON.stringify(operation.identifier)}: ${JSON.stringify(operation.requirements)},`).join('\n') + + return ` +type SecurityScheme = + | { + readonly credential: 'apiKey' + readonly in: 'header' | 'query' | 'cookie' + readonly wireName: string + } + | { + readonly credential: 'basic' | 'bearer' | 'authorization' + } + +type SecurityRequirementEntry = { + readonly name: SecuritySchemeName + readonly scopes: ReadonlyArray +} + +type ResolvedCredential = { + readonly scheme: SecurityScheme + readonly credential: SecurityCredential +} + +/** + * Names declared under OpenAPI components.securitySchemes and used by this client. + */ +export type SecuritySchemeName = ${schemeNames} + +/** + * Credential supplied for an OpenAPI API key scheme. + */ +export type ApiKeyCredential = { + readonly _tag: 'ApiKey' + readonly value: Redacted.Redacted +} + +/** + * Credential supplied for HTTP Basic authentication. + */ +export type BasicCredential = { + readonly _tag: 'Basic' + readonly username: string | Redacted.Redacted + readonly password: Redacted.Redacted +} + +/** + * Credential supplied for bearer, OAuth2, or OpenID Connect authentication. + */ +export type BearerCredential = { + readonly _tag: 'Bearer' + readonly token: Redacted.Redacted +} + +/** + * Complete Authorization header value supplied for another HTTP authentication scheme. + */ +export type AuthorizationCredential = { + readonly _tag: 'Authorization' + readonly value: Redacted.Redacted +} + +/** + * Credential accepted by the generated security resolver. + */ +export type SecurityCredential = ApiKeyCredential | BasicCredential | BearerCredential | AuthorizationCredential + +/** + * Static credentials keyed by their original OpenAPI security scheme names. + */ +export type SecurityCredentials = { +${credentialFields} +} + +/** + * Context passed to a dynamic credential resolver for each required scheme. + */ +export type SecurityCredentialRequest = { + readonly endpoint: string + readonly scheme: SecuritySchemeName + readonly scopes: ReadonlyArray +} + +/** + * Resolves a credential at request time, which supports token refresh and scoped credentials. + */ +export type SecurityCredentialResolver = ( + request: SecurityCredentialRequest, +) => Effect.Effect + +/** + * Options for the generated security client Layer. + */ +export type SecurityLayerOptions = { + /** + * Static credentials checked before the dynamic resolver. + */ + readonly credentials?: SecurityCredentials + /** + * Dynamic fallback used when a static credential is absent. + */ + readonly resolve?: SecurityCredentialResolver +} + +/** + * Indicates that no complete OpenAPI security alternative could be satisfied. + */ +export class MissingSecurityCredentials extends Data.TaggedError('MissingSecurityCredentials')<{ + readonly endpoint: string + readonly requirements: ReadonlyArray> +}> {} + +/** + * Wraps a failure raised by the dynamic credential resolver. + */ +export class SecurityCredentialResolutionError extends Data.TaggedError('SecurityCredentialResolutionError')<{ + readonly endpoint: string + readonly scheme: SecuritySchemeName + readonly cause: unknown +}> {} + +/** + * Client middleware marker attached to secured endpoints. + */ +export class ApiSecurity extends HttpApiMiddleware.Service< + ApiSecurity, + { clientError: MissingSecurityCredentials | SecurityCredentialResolutionError } +>()('kubb/ApiSecurity', { requiredForClient: true }) {} + +const securitySchemes: Readonly> = { +${schemeEntries} +} + +const securityRequirements: Readonly>>> = { +${requirementEntries} +} + +function credentialMatchesScheme({ credential, scheme }: { credential: SecurityCredential; scheme: SecurityScheme }): boolean { + if (scheme.credential === 'apiKey') return credential._tag === 'ApiKey' + if (scheme.credential === 'basic') return credential._tag === 'Basic' + if (scheme.credential === 'bearer') return credential._tag === 'Bearer' + return credential._tag === 'Authorization' +} + +function resolveCredential({ + endpoint, + entry, + options, +}: { + endpoint: string + entry: SecurityRequirementEntry + options: SecurityLayerOptions +}): Effect.Effect { + const scheme = securitySchemes[entry.name] + const credential = options.credentials?.[entry.name] + if (credential) { + return credentialMatchesScheme({ credential, scheme }) + ? Effect.succeed(credential) + : Effect.fail(new SecurityCredentialResolutionError({ endpoint, scheme: entry.name, cause: 'Credential type does not match its scheme' })) + } + if (!options.resolve) return Effect.succeed(undefined) + + return options.resolve({ endpoint, scheme: entry.name, scopes: entry.scopes }).pipe( + Effect.mapError((cause) => new SecurityCredentialResolutionError({ endpoint, scheme: entry.name, cause })), + Effect.flatMap((resolved) => { + if (!resolved || credentialMatchesScheme({ credential: resolved, scheme })) return Effect.succeed(resolved) + return Effect.fail(new SecurityCredentialResolutionError({ endpoint, scheme: entry.name, cause: 'Credential type does not match its scheme' })) + }), + ) +} + +function resolveAlternative({ + endpoint, + requirement, + options, +}: { + endpoint: string + requirement: ReadonlyArray + options: SecurityLayerOptions +}): Effect.Effect | undefined, SecurityCredentialResolutionError, R> { + return Effect.gen(function* () { + const resolved: Array = [] + for (const entry of requirement) { + const credential = yield* resolveCredential({ endpoint, entry, options }) + if (!credential) return undefined + resolved.push({ scheme: securitySchemes[entry.name], credential }) + } + return resolved + }) +} + +function applyCredential({ request, resolved }: { request: HttpClientRequest.HttpClientRequest; resolved: ResolvedCredential }) { + const { credential, scheme } = resolved + if (credential._tag === 'Basic') return HttpClientRequest.basicAuth(request, credential.username, credential.password) + if (credential._tag === 'Bearer') return HttpClientRequest.bearerToken(request, credential.token) + if (credential._tag === 'Authorization') return HttpClientRequest.setHeader(request, 'Authorization', Redacted.value(credential.value)) + + if (scheme.credential !== 'apiKey') throw new Error('API key credential resolved with a non-API-key scheme') + const value = Redacted.value(credential.value) + if (scheme.in === 'header') return HttpClientRequest.setHeader(request, scheme.wireName, value) + if (scheme.in === 'query') return HttpClientRequest.setUrlParam(request, scheme.wireName, value) + + const cookie = \`${'${encodeURIComponent(scheme.wireName)}'}=${'${encodeURIComponent(value)}'}\` + const previous = request.headers.cookie + return HttpClientRequest.setHeader(request, 'Cookie', previous ? \`${'${previous}'}; ${'${cookie}'}\` : cookie) +} + +/** + * Creates the client middleware Layer used by secured generated endpoints. + */ +export function makeSecurityLayer(options: SecurityLayerOptions = {}) { + return HttpApiMiddleware.layerClient(ApiSecurity, ({ endpoint, request, next }) => + Effect.gen(function* () { + const requirements = securityRequirements[endpoint.identifier] ?? [] + for (const requirement of requirements) { + const resolved = yield* resolveAlternative({ endpoint: endpoint.identifier, requirement, options }) + if (!resolved) continue + return yield* next(resolved.reduce((current, credential) => applyCredential({ request: current, resolved: credential }), request)) + } + + return yield* Effect.fail( + new MissingSecurityCredentials({ + endpoint: endpoint.identifier, + requirements: requirements.map((requirement) => requirement.map((entry) => entry.name)), + }), + ) + }), + ) +} +` +} diff --git a/packages/plugin-effect-httpapiclient/src/types.ts b/packages/plugin-effect-httpapiclient/src/types.ts new file mode 100644 index 000000000..f39e5c205 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/src/types.ts @@ -0,0 +1,134 @@ +import type { ast, Exclude, Group, Include, Output, OutputOptions, Override, PluginFactoryOptions, Resolver, ResolverPatch } from 'kubb/kit' + +/** + * Resolver for generated Effect HttpApi identifiers and files. + */ +export type ResolverEffectHttpApiClient = Resolver & { + /** + * Naming rules for operation endpoints. + */ + endpoint: { + /** + * Resolves the exported endpoint constant name. + */ + name(node: ast.OperationNode): string + /** + * Resolves the identifier exposed by the generated client. + */ + identifier(node: ast.OperationNode): string + } + /** + * Naming rules for HttpApi groups. + */ + group: { + /** + * Resolves the exported group constant name. + */ + name(tag: string): string + /** + * Resolves the property name exposed by a grouped client. + */ + identifier(tag: string): string + } + /** + * Naming rules for the root HttpApi contract. + */ + api: { + /** + * Resolves the exported HttpApi constant name. + */ + name(): string + } + /** + * Naming rules for the fixed HttpApiClient Effect. + */ + client: { + /** + * Resolves the exported client type and value name. + */ + name(): string + } +} + +/** + * Options for generating Effect v4 HttpApi contracts and clients. + */ +export type Options = OutputOptions & { + /** + * Base URL embedded in the generated `HttpApiClient.make` call. + */ + baseURL?: string + /** + * Controls whether client methods are grouped by the first OpenAPI tag or exposed at the root. + * + * @default 'tag' + */ + mode?: 'tag' | 'flat' + /** + * Skips operations matching at least one entry. + */ + exclude?: Array + /** + * Restricts generation to matching operations. + */ + include?: Array + /** + * Applies different options to matching operations. + */ + override?: Array> + /** + * Overrides generated endpoint, group, API, client, and file names. + */ + resolver?: ResolverPatch + /** + * Macros applied before printing each operation. + */ + macros?: Array +} + +/** + * Fully resolved options supplied to the Effect HttpApiClient generator. + */ +export type ResolvedOptions = { + /** + * Resolved output target. + */ + output: Output + /** + * Resolved operation exclusions. + */ + exclude: Array + /** + * Resolved operation inclusions. + */ + include: Array | undefined + /** + * Resolved per-operation overrides. + */ + override: Array> + /** + * Resolved output grouping. + */ + group: Group | null + /** + * Base URL embedded in the generated client. + */ + baseURL: string | undefined + /** + * Generated client grouping mode. + */ + mode: NonNullable +} + +/** + * Kubb registry entry for `@kubb/plugin-effect-httpapiclient`. + */ +export type PluginEffectHttpApiClient = PluginFactoryOptions<'plugin-effect-httpapiclient', Options, ResolvedOptions, ResolverEffectHttpApiClient> + +declare global { + namespace Kubb { + interface PluginRegistry { + 'plugin-effect-httpapiclient': PluginEffectHttpApiClient + } + } +} diff --git a/packages/plugin-effect-httpapiclient/tsconfig.json b/packages/plugin-effect-httpapiclient/tsconfig.json new file mode 100644 index 000000000..f5c1b7f74 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "kubb/jsx", + "types": ["bun-types", "../../reset.d.ts"] + }, + "include": ["src/**/*", "./package.json", "./tsdown.config.ts", "./vitest.config.ts"] +} diff --git a/packages/plugin-effect-httpapiclient/tsdown.config.ts b/packages/plugin-effect-httpapiclient/tsdown.config.ts new file mode 100644 index 000000000..7ae389f16 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/tsdown.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, type UserConfig } from 'tsdown' + +const entry = { + index: 'src/index.ts', +} + +const shared: Partial = { + platform: 'node', + sourcemap: true, + shims: true, + exports: true, + deps: { + neverBundle: [/^@kubb\//], + alwaysBundle: [/@internals/], + }, + fixedExtension: false, + outputOptions: { + keepNames: true, + }, +} + +export default defineConfig([ + { + entry, + format: 'esm', + dts: true, + ...shared, + }, + { + entry, + format: 'cjs', + dts: false, + ...shared, + }, +]) diff --git a/packages/plugin-effect-httpapiclient/vitest.config.ts b/packages/plugin-effect-httpapiclient/vitest.config.ts new file mode 100644 index 000000000..c84a98928 --- /dev/null +++ b/packages/plugin-effect-httpapiclient/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + dir: './src', + }, + resolve: { + tsconfigPaths: true, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 457bd1d4f..4ec7893f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,6 +273,27 @@ importers: specifier: 'catalog:' version: 6.0.3 + examples/effect-httpapiclient: + dependencies: + '@kubb/adapter-oas': + specifier: 'catalog:' + version: 5.0.0-beta.97(openapi-types@12.1.3) + '@kubb/plugin-effect': + specifier: workspace:* + version: link:../../packages/plugin-effect + '@kubb/plugin-effect-httpapiclient': + specifier: workspace:* + version: link:../../packages/plugin-effect-httpapiclient + effect: + specifier: 'catalog:' + version: 4.0.0-beta.98 + kubb: + specifier: 'catalog:' + version: 5.0.0-beta.97(openapi-types@12.1.3)(rolldown@1.1.5)(typescript@6.0.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + typescript: + specifier: 'catalog:' + version: 6.0.3 + examples/faker: dependencies: '@faker-js/faker': @@ -710,6 +731,25 @@ importers: specifier: 'catalog:' version: 5.0.0-beta.97(openapi-types@12.1.3)(rolldown@1.1.5)(typescript@6.0.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/plugin-effect-httpapiclient: + dependencies: + '@kubb/plugin-effect': + specifier: workspace:* + version: link:../plugin-effect + devDependencies: + '@internals/shared': + specifier: workspace:* + version: link:../../internals/shared + '@internals/utils': + specifier: workspace:* + version: link:../../internals/utils + effect: + specifier: 'catalog:' + version: 4.0.0-beta.98 + kubb: + specifier: 'catalog:' + version: 5.0.0-beta.97(openapi-types@12.1.3)(rolldown@1.1.5)(typescript@6.0.3)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/plugin-faker: dependencies: '@kubb/plugin-ts': diff --git a/tsconfig.json b/tsconfig.json index f32b84868..0605e4d5d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "@kubb/plugin-cypress": ["./packages/plugin-cypress/src/index.ts"], "@kubb/plugin-zod/utils": ["./packages/plugin-zod/src/utils/index.ts"], "@kubb/plugin-zod": ["./packages/plugin-zod/src/index.ts"], - "@kubb/plugin-effect": ["./packages/plugin-effect/src/index.ts"] + "@kubb/plugin-effect": ["./packages/plugin-effect/src/index.ts"], + "@kubb/plugin-effect-httpapiclient": ["./packages/plugin-effect-httpapiclient/src/index.ts"] } }, "include": ["./package.json", "./tsdown.config.ts", "./vitest.config.ts", "./src/**/*", "tests"], From fcb5147d187572e35fe9c576038b933695763592 Mon Sep 17 00:00:00 2001 From: "NMNM.CC" Date: Wed, 15 Jul 2026 00:03:14 +0800 Subject: [PATCH 3/4] fix(plugin-effect): handle nested refs and numeric codecs --- .../src/generators/effectGenerator.tsx | 33 +++++++++------- .../src/printers/printerEffect.test.ts | 38 +++++++++++++++++-- .../src/printers/printerEffect.ts | 35 +++++++++++++++-- 3 files changed, 84 insertions(+), 22 deletions(-) diff --git a/packages/plugin-effect/src/generators/effectGenerator.tsx b/packages/plugin-effect/src/generators/effectGenerator.tsx index 9be3fb1d7..233755a0c 100644 --- a/packages/plugin-effect/src/generators/effectGenerator.tsx +++ b/packages/plugin-effect/src/generators/effectGenerator.tsx @@ -11,24 +11,29 @@ type ResponseUnionOptions = { fallbackUnknown: boolean } +function containsSchema(node: ast.SchemaNode, predicate: (schema: ast.SchemaNode) => boolean, seen: ReadonlySet = new Set()): boolean { + if (predicate(node)) return true + if (node.type === 'ref') { + const name = ast.resolveRefName(node) + if (name && seen.has(name)) return false + const resolved = ast.syncSchemaRef(node) + if (resolved.type !== 'ref') return containsSchema(resolved, predicate, name ? new Set([...seen, name]) : seen) + } + if ('properties' in node && node.properties?.some((property) => containsSchema(property.schema, predicate, seen))) return true + if ('items' in node && node.items?.some((item) => containsSchema(item, predicate, seen))) return true + if ('members' in node && node.members?.some((member) => containsSchema(member, predicate, seen))) return true + if ('additionalProperties' in node && node.additionalProperties && node.additionalProperties !== true) { + return containsSchema(node.additionalProperties, predicate, seen) + } + return false +} + function needsSchemaGetter(node: ast.SchemaNode): boolean { - return ast - .collect(node, { - schema(schema) { - return schema.type === 'date' && schema.representation === 'date' && schema.format === 'date' ? true : undefined - }, - }) - .some(Boolean) + return containsSchema(node, (schema) => schema.type === 'bigint' || (schema.type === 'date' && schema.representation === 'date' && schema.format === 'date')) } function needsDateTime(node: ast.SchemaNode): boolean { - return ast - .collect(node, { - schema(schema) { - return schema.type === 'date' && schema.representation === 'date' && schema.format !== 'date' ? true : undefined - }, - }) - .some(Boolean) + return containsSchema(node, (schema) => schema.type === 'date' && schema.representation === 'date' && schema.format !== 'date') } /** diff --git a/packages/plugin-effect/src/printers/printerEffect.test.ts b/packages/plugin-effect/src/printers/printerEffect.test.ts index a6df807e8..c16601018 100644 --- a/packages/plugin-effect/src/printers/printerEffect.test.ts +++ b/packages/plugin-effect/src/printers/printerEffect.test.ts @@ -13,7 +13,7 @@ describe('printerEffect', () => { encoded: 'string', }) expect(printer.print(ast.factory.createSchema({ type: 'integer', min: 1, exclusiveMaximum: 10, multipleOf: 2 }))).toEqual({ - runtime: 'Schema.Number.check(Schema.isFinite(), Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThan(10), Schema.isMultipleOf(2))', + runtime: 'Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThan(10), Schema.isMultipleOf(2))', type: 'number', encoded: 'number', }) @@ -36,8 +36,7 @@ describe('printerEffect', () => { ], }) expect(printer.print(node)).toEqual({ - runtime: - 'Schema.Struct({ id: Schema.Number.check(Schema.isFinite(), Schema.isInt()), "display-name": Schema.optionalKey(Schema.NullOr(Schema.String)) })', + runtime: 'Schema.Struct({ id: Schema.Int, "display-name": Schema.optionalKey(Schema.NullOr(Schema.String)) })', type: '{ readonly id: number; readonly "display-name"?: string | null }', encoded: '{ readonly id: number; readonly "display-name"?: string | null }', }) @@ -52,7 +51,7 @@ describe('printerEffect', () => { strategy: 'one', members: [ast.factory.createSchema({ type: 'string' }), ast.factory.createSchema({ type: 'number' })], }) - expect(printer.print(oneOf)?.runtime).toBe('Schema.Union([Schema.String, Schema.Number.check(Schema.isFinite())], { mode: "oneOf" })') + expect(printer.print(oneOf)?.runtime).toBe('Schema.Union([Schema.String, Schema.Finite], { mode: "oneOf" })') }) test('omits fields from referenced operation schemas', () => { @@ -81,6 +80,15 @@ describe('printerEffect', () => { }) }) + test('decodes JSON int64 numbers to bigint values', () => { + expect(printer.print(ast.factory.createSchema({ type: 'bigint', format: 'int64', examples: [100_000] }))).toEqual({ + runtime: + 'Schema.Int.pipe(Schema.decodeTo(Schema.BigInt, { decode: SchemaGetter.transform((value) => BigInt(value)), encode: SchemaGetter.transform((value) => Number(value)) })).annotate({ format: "int64", examples: [BigInt("100000")] })', + type: 'bigint', + encoded: 'number', + }) + }) + test('prints Effect DateTime codecs and annotations', () => { const node = ast.factory.createSchema({ type: 'date', @@ -97,6 +105,28 @@ describe('printerEffect', () => { }) }) + test('converts DateTime values inside referenced object examples', () => { + const schema = ast.factory.createSchema({ + type: 'object', + properties: [ + ast.factory.createProperty({ + name: 'createdDate', + required: true, + schema: ast.factory.createSchema({ type: 'date', representation: 'date', format: 'date-time' }), + }), + ], + }) + const ref = ast.factory.createSchema({ + type: 'ref', + name: 'Order', + ref: '#/components/schemas/Order', + schema, + examples: [{ createdDate: '2026-07-14T10:30:00.000Z' }], + }) + + expect(printer.print(ref)?.runtime).toBe('Order.annotate({ examples: [{ createdDate: DateTime.makeUnsafe("2026-07-14T10:30:00.000Z") }] })') + }) + test('prints recursive refs with an explicit codec contract', () => { const cyclic = printerEffect({ resolver: resolverEffect, cyclicSchemas: new Set(['Pet']), currentSchemaName: 'Pet' }) expect(cyclic.print(ast.factory.createSchema({ type: 'ref', name: 'Pet', ref: '#/components/schemas/Pet' }))).toEqual({ diff --git a/packages/plugin-effect/src/printers/printerEffect.ts b/packages/plugin-effect/src/printers/printerEffect.ts index 5d16323b3..b74e7e674 100644 --- a/packages/plugin-effect/src/printers/printerEffect.ts +++ b/packages/plugin-effect/src/printers/printerEffect.ts @@ -91,7 +91,29 @@ function valueLiteral(value: unknown): string { return 'undefined' } -function annotationLiteral(node: ast.SchemaNode, value: unknown): string { +function annotationLiteral(node: ast.SchemaNode, value: unknown, seen: ReadonlySet = new Set()): string { + if (node.type === 'ref') { + const name = ast.resolveRefName(node) + if (name && seen.has(name)) return valueLiteral(value) + const resolved = ast.syncSchemaRef(node) + if (resolved.type !== 'ref') return annotationLiteral(resolved, value, name ? new Set([...seen, name]) : seen) + } + if (node.type === 'object' && typeof value === 'object' && value !== null && !Array.isArray(value)) { + const properties = new Map(node.properties?.map((property) => [property.name, property.schema])) + return `{ ${Object.entries(value) + .map(([key, item]) => { + const property = properties.get(key) + return `${propertyKey(key)}: ${property ? annotationLiteral(property, item, seen) : valueLiteral(item)}` + }) + .join(', ')} }` + } + if (node.type === 'array' && Array.isArray(value)) { + const item = node.items?.[0] + return `[${value.map((entry) => (item ? annotationLiteral(item, entry, seen) : valueLiteral(entry))).join(', ')}]` + } + if (node.type === 'tuple' && Array.isArray(value)) { + return `[${value.map((entry, index) => (node.items?.[index] ? annotationLiteral(node.items[index]!, entry, seen) : valueLiteral(entry))).join(', ')}]` + } if (node.type === 'bigint' && (typeof value === 'number' || typeof value === 'string' || typeof value === 'bigint')) { return `BigInt(${JSON.stringify(String(value))})` } @@ -341,12 +363,17 @@ export const printerEffect = ast.createPrinter((options) = return schemaCode(`Schema.String${lengthChecks(node, this.options.regexType)}`, 'string') }, number(node) { - return schemaCode(`Schema.Number${checks(['Schema.isFinite()', ...numberChecks(node)])}`, 'number') + return schemaCode(`Schema.Finite${checks(numberChecks(node))}`, 'number') }, integer(node) { - return schemaCode(`Schema.Number${checks(['Schema.isFinite()', 'Schema.isInt()', ...numberChecks(node)])}`, 'number') + return schemaCode(`Schema.Int${checks(numberChecks(node))}`, 'number') }, - bigint: () => schemaCode('Schema.BigInt', 'bigint'), + bigint: () => + schemaCode( + 'Schema.Int.pipe(Schema.decodeTo(Schema.BigInt, { decode: SchemaGetter.transform((value) => BigInt(value)), encode: SchemaGetter.transform((value) => Number(value)) }))', + 'bigint', + 'number', + ), date(node) { if (node.representation !== 'date') return schemaCode('Schema.String', 'string') if (node.format === 'date') { From 7463a12eab04493b3cd69e4e07320ac2a33e9aea Mon Sep 17 00:00:00 2001 From: "NMNM.CC" Date: Wed, 15 Jul 2026 00:03:14 +0800 Subject: [PATCH 4/4] fix(plugin-effect-httpapiclient): handle default responses and security runtime --- .../generators/httpApiClientGenerator.test.tsx | 18 +++++++++++++++++- .../src/generators/httpApiClientGenerator.tsx | 1 + .../src/securityRuntime.ts | 12 +++++------- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx index 5c48568a5..fc08bbe93 100644 --- a/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx +++ b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.test.tsx @@ -85,7 +85,14 @@ const operations: Array = [ method: 'GET', path: '/orders', tags: ['store'], - responses: [ast.factory.createResponse({ statusCode: '204', description: 'No content' })], + responses: [ + ast.factory.createResponse({ statusCode: '204', description: 'No content' }), + ast.factory.createResponse({ + statusCode: 'default', + description: 'Unspecified error', + content: [ast.factory.createContent({ contentType: 'application/json', schema: ast.factory.createSchema({ type: 'object', properties: [] }) })], + }), + ], }), ] @@ -132,6 +139,7 @@ describe('httpApiClientGenerator', () => { expect(output).toContain('HttpApiGroup.make("pets")') expect(output).toContain('HttpApiClient.make(Api, { baseUrl: "https://example.com" })') expect(output).toContain('"getPet": [[{"name":"apiKey","scopes":[]}]]') + expect(output).toContain("request.headers['cookie']") }) test('marks groups as top-level in flat mode', async () => { @@ -141,4 +149,12 @@ describe('httpApiClientGenerator', () => { expect(api).toContain('HttpApiGroup.make("pets", { topLevel: true })') expect(api).toContain('HttpApiGroup.make("store", { topLevel: true })') }) + + test('omits default responses that Effect HttpApi cannot represent as a concrete status', async () => { + const sources = await render(defaultOptions) + const output = sources.join('\n') + + expect(output).toContain('HttpApiSchema.Empty(204)') + expect(output).not.toContain('ListOrdersDefault') + }) }) diff --git a/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx index 036c4b834..9de9b8743 100644 --- a/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx +++ b/packages/plugin-effect-httpapiclient/src/generators/httpApiClientGenerator.tsx @@ -284,6 +284,7 @@ function renderResponses({ node, effectResolver }: { node: ast.HttpOperationNode const imports: Array = [] for (const response of node.responses) { + if (response.statusCode === 'default') continue const status = getStatusCodeNumber(response.statusCode) if (status === null) throw new Error(`Operation "${node.operationId}" uses unsupported non-numeric response status "${response.statusCode}"`) const target = status >= 200 && status < 300 ? success : error diff --git a/packages/plugin-effect-httpapiclient/src/securityRuntime.ts b/packages/plugin-effect-httpapiclient/src/securityRuntime.ts index 7765d4209..209e96e17 100644 --- a/packages/plugin-effect-httpapiclient/src/securityRuntime.ts +++ b/packages/plugin-effect-httpapiclient/src/securityRuntime.ts @@ -239,7 +239,7 @@ function applyCredential({ request, resolved }: { request: HttpClientRequest.Htt if (scheme.in === 'query') return HttpClientRequest.setUrlParam(request, scheme.wireName, value) const cookie = \`${'${encodeURIComponent(scheme.wireName)}'}=${'${encodeURIComponent(value)}'}\` - const previous = request.headers.cookie + const previous = request.headers['cookie'] return HttpClientRequest.setHeader(request, 'Cookie', previous ? \`${'${previous}'}; ${'${cookie}'}\` : cookie) } @@ -256,12 +256,10 @@ export function makeSecurityLayer(options: SecurityLayerOp return yield* next(resolved.reduce((current, credential) => applyCredential({ request: current, resolved: credential }), request)) } - return yield* Effect.fail( - new MissingSecurityCredentials({ - endpoint: endpoint.identifier, - requirements: requirements.map((requirement) => requirement.map((entry) => entry.name)), - }), - ) + return yield* new MissingSecurityCredentials({ + endpoint: endpoint.identifier, + requirements: requirements.map((requirement) => requirement.map((entry) => entry.name)), + }) }), ) }