Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
reports/

18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@
"packages/*"
],
"scripts": {
"build": "pnpm --filter @effect-template/app build",
"check": "pnpm --filter @effect-template/app check",
"build": "pnpm --filter @prover-coder-ai/openapi-effect build",
"check": "pnpm --filter @prover-coder-ai/openapi-effect check",
"changeset": "changeset",
"changeset-publish": "node -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish",
"changeset-version": "changeset version",
"dev": "pnpm --filter @effect-template/app dev",
"lint": "pnpm --filter @effect-template/app lint",
"lint:tests": "pnpm --filter @effect-template/app lint:tests",
"lint:effect": "pnpm --filter @effect-template/app lint:effect",
"test": "pnpm --filter @effect-template/app test",
"typecheck": "pnpm --filter @effect-template/app typecheck",
"start": "pnpm --filter @effect-template/app start"
"dev": "pnpm --filter @prover-coder-ai/openapi-effect dev",
"lint": "pnpm --filter @prover-coder-ai/openapi-effect lint",
"lint:tests": "pnpm --filter @prover-coder-ai/openapi-effect lint:tests",
"lint:effect": "pnpm --filter @prover-coder-ai/openapi-effect lint:effect",
"test": "pnpm --filter @prover-coder-ai/openapi-effect test",
"typecheck": "pnpm --filter @prover-coder-ai/openapi-effect typecheck",
"start": "pnpm --filter @prover-coder-ai/openapi-effect start"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.2",
Expand Down
25 changes: 17 additions & 8 deletions packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
{
"name": "@effect-template/app",
"name": "@prover-coder-ai/openapi-effect",
"version": "1.0.16",
"description": "Minimal Vite-powered TypeScript console starter using Effect",
"main": "dist/main.js",
"directories": {
"doc": "doc"
"description": "Drop-in replacement for openapi-fetch with an opt-in Effect API",
"type": "module",
"main": "dist/index.js",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "vite build --ssr src/app/main.ts",
"dev": "vite build --watch --ssr src/app/main.ts",
"build": "vite build",
"dev": "vite build --watch",
"lint": "npx @ton-ai-core/vibecode-linter src/",
"lint:tests": "npx @ton-ai-core/vibecode-linter tests/",
"lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .",
Expand All @@ -32,7 +41,6 @@
],
"author": "",
"license": "ISC",
"type": "module",
"bugs": {
"url": "https://github.com/ProverCoderAI/effect-template/issues"
},
Expand All @@ -52,6 +60,7 @@
"@effect/typeclass": "^0.38.0",
"@effect/workflow": "^0.16.0",
"effect": "^3.19.16",
"openapi-fetch": "^0.15.2",
"openapi-typescript-helpers": "^0.1.0",
"ts-morph": "^27.0.2"
},
Expand Down
54 changes: 18 additions & 36 deletions packages/app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,39 @@
// CHANGE: Main entry point for openapi-effect package with Effect-native error handling
// WHY: Enable default import of createClient function with proper error channel design
// QUOTE(ТЗ): "import createClient from \"openapi-effect\""
// REF: PR#3 comment from skulidropek about Effect representation
// CHANGE: Make openapi-effect a drop-in replacement for openapi-fetch (Promise API), with an opt-in Effect API.
// WHY: Consumer projects must be able to swap openapi-fetch -> openapi-effect with near-zero code changes.
// QUOTE(ТЗ): "openapi-effect должен почти 1 в 1 заменяться с openapi-fetch" / "Просто добавлять effect поведение"
// SOURCE: n/a
// PURITY: SHELL (re-exports)
// COMPLEXITY: O(1)

// High-level API (recommended for most users)
export { createClient as default } from "./shell/api-client/create-client.js"
// Promise-based client (openapi-fetch compatible)
export { default } from "openapi-fetch"
export { default as createClient } from "openapi-fetch"
export * from "openapi-fetch"

// Effect-based client (opt-in)
export * as FetchHttpClient from "@effect/platform/FetchHttpClient"

// Strict Effect client (advanced)
export type * from "./core/api-client/index.js"
export { assertNever } from "./core/api-client/index.js"

export type {
ClientEffect,
ClientOptions,
DispatchersFor,
StrictApiClient,
StrictApiClientWithDispatchers
} from "./shell/api-client/create-client.js"
export { createClientEffect, registerDefaultDispatchers } from "./shell/api-client/create-client.js"

// Core types (for advanced type manipulation)
// Effect Channel Design:
// - ApiSuccess<Responses>: 2xx responses → success channel
// - ApiFailure<Responses>: HttpError (4xx, 5xx) + BoundaryError → error channel
export type {
ApiFailure,
ApiSuccess,
BodyFor,
BoundaryError,
ContentTypesFor,
DecodeError,
HttpError,
HttpErrorResponseVariant,
HttpErrorVariants,
OperationFor,
ParseError,
PathsForMethod,
ResponsesFor,
ResponseVariant,
StatusCodes,
SuccessVariants,
TransportError,
UnexpectedContentType,
UnexpectedStatus
} from "./core/api-client/index.js"

// Shell utilities (for custom implementations)
export type { Decoder, Dispatcher, RawResponse, StrictClient, StrictRequestInit } from "./shell/api-client/index.js"

export {
createClient as createClientStrict,
createClientEffect,
createDispatcher,
createStrictClient,
createUniversalDispatcher,
executeRequest,
parseJSON,
registerDefaultDispatchers,
unexpectedContentType,
unexpectedStatus
} from "./shell/api-client/index.js"
Expand Down
8 changes: 2 additions & 6 deletions packages/app/src/shell/api-client/create-client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type * as HttpClient from "@effect/platform/HttpClient"
import type { Effect } from "effect"
import type { ClientOptions as OpenapiFetchClientOptions } from "openapi-fetch"
import type { HttpMethod } from "openapi-typescript-helpers"

import type {
Expand All @@ -28,12 +29,7 @@ import type { Dispatcher } from "../../core/axioms.js"
*
* @pure - immutable configuration
*/
export type ClientOptions = {
readonly baseUrl: string
readonly credentials?: RequestCredentials
readonly headers?: HeadersInit
readonly fetch?: typeof globalThis.fetch
}
export type ClientOptions = OpenapiFetchClientOptions

// CHANGE: Add dispatcher map type for auto-dispatching clients
// WHY: Enable creating clients that infer dispatcher from path+method without per-call parameter
Expand Down
137 changes: 102 additions & 35 deletions packages/app/src/shell/api-client/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,53 @@ const resolveDefaultDispatchers = <Paths extends object>(): DispatchersFor<Paths
return asDispatchersFor<DispatchersFor<Paths>>(defaultDispatchers)
}

const applyPathParams = (path: string, params?: Record<string, ParamValue>): string => {
if (params === undefined) {
return path
}

let url = path
for (const [key, value] of Object.entries(params)) {
url = url.replace("{" + key + "}", encodeURIComponent(String(value)))
}
return url
}

const buildQueryString = (query?: Record<string, QueryValue>): string => {
if (query === undefined) {
return ""
}

const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
for (const item of value) {
searchParams.append(key, String(item))
}
continue
}
searchParams.set(key, String(value))
}
return searchParams.toString()
}

const appendQueryString = (url: string, queryString: string): string => {
if (queryString.length === 0) {
return url
}
return url.includes("?") ? url + "&" + queryString : url + "?" + queryString
}

const withBaseUrl = (baseUrl: string | undefined, url: string): string => {
// If baseUrl is not provided, keep a relative URL (browser-friendly)
if (baseUrl === undefined || baseUrl === "") {
return url
}

// Construct full URL
return new URL(url, baseUrl).toString()
}

/**
* Build URL with path parameters and query string
*
Expand All @@ -96,36 +143,15 @@ const resolveDefaultDispatchers = <Paths extends object>(): DispatchersFor<Paths
* @complexity O(n + m) where n = |params|, m = |query|
*/
const buildUrl = (
baseUrl: string,
baseUrl: string | undefined,
path: string,
params?: Record<string, ParamValue>,
query?: Record<string, QueryValue>
): string => {
// Replace path parameters
let url = path
if (params) {
for (const [key, value] of Object.entries(params)) {
url = url.replace(`{${key}}`, encodeURIComponent(String(value)))
}
}

// Construct full URL
const fullUrl = new URL(url, baseUrl)

// Add query parameters
if (query) {
for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
for (const item of value) {
fullUrl.searchParams.append(key, String(item))
}
} else {
fullUrl.searchParams.set(key, String(value))
}
}
}

return fullUrl.toString()
const urlWithParams = applyPathParams(path, params)
const queryString = buildQueryString(query)
const urlWithQuery = appendQueryString(urlWithParams, queryString)
return withBaseUrl(baseUrl, urlWithQuery)
}

/**
Expand Down Expand Up @@ -172,22 +198,63 @@ const needsJsonContentType = (body: BodyInit | object | undefined): boolean =>
&& !(body instanceof Blob)
&& !(body instanceof FormData)

const toHeadersFromRecord = (
headersInit: Record<
string,
| string
| number
| boolean
| ReadonlyArray<string | number | boolean>
| null
| undefined
>
): Headers => {
const headers = new Headers()

for (const [key, value] of Object.entries(headersInit)) {
if (value === null || value === undefined) {
continue
}
if (Array.isArray(value)) {
headers.set(key, value.map(String).join(","))
continue
}
headers.set(key, String(value))
}

return headers
}

/**
* Merge headers from client options and request options
*
* @pure true
* @complexity O(n) where n = number of headers
*/
const toHeaders = (headersInit: ClientOptions["headers"] | undefined): Headers => {
if (headersInit === undefined) {
return new Headers()
}

if (headersInit instanceof Headers) {
return new Headers(headersInit)
}

if (Array.isArray(headersInit)) {
return new Headers(headersInit)
}

return toHeadersFromRecord(headersInit)
}

const mergeHeaders = (
clientHeaders: HeadersInit | undefined,
requestHeaders: HeadersInit | undefined
clientHeaders: ClientOptions["headers"] | undefined,
requestHeaders: ClientOptions["headers"] | undefined
): Headers => {
const headers = new Headers(clientHeaders)
if (requestHeaders) {
const optHeaders = new Headers(requestHeaders)
for (const [key, value] of optHeaders.entries()) {
headers.set(key, value)
}
const headers = toHeaders(clientHeaders)
const optHeaders = toHeaders(requestHeaders)
for (const [key, value] of optHeaders.entries()) {
headers.set(key, value)
}
return headers
}
Expand All @@ -201,7 +268,7 @@ type MethodHandlerOptions = {
params?: Record<string, ParamValue> | undefined
query?: Record<string, QueryValue> | undefined
body?: BodyInit | object | undefined
headers?: HeadersInit | undefined
headers?: ClientOptions["headers"] | undefined
signal?: AbortSignal | undefined
}

Expand Down
Loading