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
36 changes: 34 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **BREAKING**: Complete semantic overhaul of response types based on correct understanding
- ALL responses (GET, POST, PUT, PATCH, DELETE) now use the SAME type - no distinction between query and mutation responses
- `ApiResponse` (default): Makes ALL fields required for all endpoint responses - assumes server always returns all fields
- New `ApiResponseStrict` (opt-in via `--use-strict-response`): Only marks fields as required if they are readonly OR marked as required in OpenAPI spec
- `readonly` modifier ONLY affects request bodies (mutations), NOT response types
- Request bodies always exclude readonly fields (client cannot set them) regardless of mode

### Removed

- **BREAKING**: `ApiResponseSafe` type removed (was based on incorrect understanding of requirements)
- **BREAKING**: `--use-query-safe-response` CLI flag removed

### Added

- `--use-strict-response` CLI flag to use `ApiResponseStrict` for all responses (defaults to `false`)
- `ApiResponseStrict` type for strict OpenAPI spec adherence (only readonly/required fields are required)
- `RequireReadonlyOrRequired<T>` type helper for strict mode
- Comprehensive documentation of new response type semantics

### Migration

1. Replace `ApiResponseSafe` with `ApiResponse` (default behavior) or `ApiResponseStrict` (strict mode)
2. Remove `--use-query-safe-response` flag usage - use `--use-strict-response` if you need strict mode
3. Understand that ALL responses now use the same type - no query vs mutation distinction
4. Note that `readonly` only affects what you POST (request bodies), not what API returns (responses)

## [0.18.3] - 2026-02-27

### Fixed
Expand Down Expand Up @@ -198,9 +228,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `GetPathParameters<Ops, Op>` → `ApiPathParams<Op>`
- `GetQueryParameters<Ops, Op>` → `ApiQueryParams<Op>`
- Old names removed entirely (no backward compatibility aliases)
- **BREAKING**: `ApiResponse` now requires ALL fields regardless of `required` status in OpenAPI schema
- All response fields are now required - no null checks needed
- **BREAKING**: `ApiResponse` made ALL fields required regardless of `required` status in OpenAPI schema
- All response fields required - no null checks needed
- Added `ApiResponseSafe` for opt-out: only readonly fields required, others preserve optional status
- **NOTE**: This implementation was based on incorrect understanding - see v0.19.0 for corrected semantics
- **BREAKING**: Made `isQueryMethod` and `isMutationMethod` internal (not exported from public API)
- Removed `types-documentation.ts` - type documentation now inline in `types.ts`
- Simplified `index.ts` exports - all public types exported directly from `types.ts`
Expand All @@ -210,6 +241,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- `ApiResponseSafe<Op>` type for unreliable backends - only readonly fields required, others optional
- **NOTE**: Removed in v0.19.0 due to incorrect semantics

### Removed

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,19 +171,19 @@ const mutation = api.createPet.useMutation({

```typescript
import type {
ApiResponse, // Response type (all fields required)
ApiResponseSafe, // Response with optional fields
ApiResponse, // Response type (ALL fields required - default)
ApiResponseStrict, // Response type (only readonly/required fields required - strict mode)
ApiRequest, // Request body type
ApiPathParams, // Path parameters type
ApiQueryParams, // Query parameters type
} from './generated/api-operations'

// ApiResponse - ALL fields required
// ApiResponse - default, ALL fields required
type PetResponse = ApiResponse<OpType.getPet>
// { readonly id: string, name: string, tag: string, status: 'available' | ... }

// ApiResponseSafe - only readonly required, others optional
type PetResponseSafe = ApiResponseSafe<OpType.getPet>
// ApiResponseStrict - strict mode, only readonly/required fields required
type PetResponseStrict = ApiResponseStrict<OpType.getPet>
// { readonly id: string, name: string, tag?: string, status?: 'available' | ... }
```

Expand Down
94 changes: 68 additions & 26 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,7 @@ function _generateOperationJSDoc(operationId: string, method: string, apiPath: s
return lines.join('\n')
}

function generateApiClientContent(operationMap: Record<string, OperationInfo>): string {
function generateApiClientContent(operationMap: Record<string, OperationInfo>, useStrictResponse = false): string {
const ids = Object.keys(operationMap).sort()
const QUERY_HTTP = new Set(['GET', 'HEAD', 'OPTIONS'])
const isQuery = (id: string) => QUERY_HTTP.has(operationMap[id].method)
Expand All @@ -1040,6 +1040,9 @@ function generateApiClientContent(operationMap: Record<string, OperationInfo>):
// Registry for invalidateOperations support
const registryEntries = ids.map((id) => ` ${id}: { path: '${operationMap[id].path}' },`).join('\n')

// Response type to use for ALL operations (queries and mutations)
const responseType = useStrictResponse ? 'ApiResponseStrict' : 'ApiResponse'

// Generic factory helpers (4 patterns)
const helpers = `/**
* Generic query helper for operations without path parameters.
Expand All @@ -1050,7 +1053,7 @@ function _queryNoParams<Op extends AllOps>(
cfg: { path: string; method: HttpMethod; listPath: string | null },
enums: Record<string, unknown>,
) {
type Response = ApiResponse<Op>
type Response = ${responseType}<Op>
type QueryParams = ApiQueryParams<Op>

const useQuery = (
Expand Down Expand Up @@ -1117,7 +1120,7 @@ function _queryWithParams<Op extends AllOps>(
) {
type PathParams = ApiPathParams<Op>
type PathParamsInput = ApiPathParamsInput<Op>
type Response = ApiResponse<Op>
type Response = ${responseType}<Op>
type QueryParams = ApiQueryParams<Op>

// Two-overload interface: non-function (exact via object-literal checking) +
Expand Down Expand Up @@ -1211,7 +1214,7 @@ function _mutationNoParams<Op extends AllOps>(
enums: Record<string, unknown>,
) {
type RequestBody = ApiRequest<Op>
type Response = ApiResponse<Op>
type Response = ${responseType}<Op>
type QueryParams = ApiQueryParams<Op>

const useMutation = (
Expand Down Expand Up @@ -1256,7 +1259,7 @@ function _mutationWithParams<Op extends AllOps>(
type PathParams = ApiPathParams<Op>
type PathParamsInput = ApiPathParamsInput<Op>
type RequestBody = ApiRequest<Op>
type Response = ApiResponse<Op>
type Response = ${responseType}<Op>
type QueryParams = ApiQueryParams<Op>

// Three-overload interface:
Expand Down Expand Up @@ -1392,7 +1395,7 @@ import {
import type { QueryClient } from '@tanstack/vue-query'

import type {
ApiResponse,
ApiResponse${useStrictResponse ? ',\n ApiResponseStrict' : ''},
ApiRequest,
ApiPathParams,
ApiPathParamsInput,
Expand Down Expand Up @@ -1481,9 +1484,10 @@ async function generateApiClientFile(
openApiSpec: OpenAPISpec,
outputDir: string,
excludePrefix: string | null,
useStrictResponse = false,
): Promise<void> {
const operationMap = buildOperationMap(openApiSpec, excludePrefix)
const content = generateApiClientContent(operationMap)
const content = generateApiClientContent(operationMap, useStrictResponse)
fs.writeFileSync(path.join(outputDir, 'api-client.ts'), content)
console.log(`✅ Generated api-client.ts (${Object.keys(operationMap).length} operations)`)
}
Expand All @@ -1499,16 +1503,18 @@ Arguments:
output-directory Directory where generated files will be saved

Options:
--exclude-prefix PREFIX Exclude operations with operationId starting with PREFIX
(default: '_deprecated')
--no-exclude Disable operation exclusion (include all operations)
--help, -h Show this help message
--exclude-prefix PREFIX Exclude operations with operationId starting with PREFIX
(default: '_deprecated', use 'false' to disable)
--use-strict-response Use ApiResponseStrict for responses (only readonly/required fields required)
(default: false; when disabled, ALL fields required)
--help, -h Show this help message

Examples:
npx @qualisero/openapi-endpoint ./api/openapi.json ./src/generated
npx @qualisero/openapi-endpoint https://api.example.com/openapi.json ./src/api
npx @qualisero/openapi-endpoint ./api.json ./src/gen --exclude-prefix _internal
npx @qualisero/openapi-endpoint ./api.json ./src/gen --no-exclude
npx @qualisero/openapi-endpoint ./api.json ./src/gen --exclude-prefix false
npx @qualisero/openapi-endpoint ./api.json ./src/gen --use-strict-response true

This command will generate:
- openapi-types.ts (TypeScript types from OpenAPI spec)
Expand All @@ -1517,6 +1523,20 @@ This command will generate:
- api-types.ts (Types namespace for type-only access)
- api-enums.ts (Type-safe enum objects from OpenAPI spec)
- api-schemas.ts (Type aliases for schema objects from OpenAPI spec)

Response Typing (--use-strict-response):
By default, ApiResponse makes ALL fields required for all endpoint responses
(GET, POST, PUT, PATCH, DELETE). This assumes the API always returns all fields
regardless of how they're marked in the OpenAPI spec.

When --use-strict-response is enabled, ApiResponseStrict is used instead, which
only marks fields as required if they are:
- readonly (server-generated fields like 'id'), OR
- marked as required in the OpenAPI spec
All other fields remain optional.

Note: readonly only affects REQUEST BODIES (mutations), not response types.
Request bodies always exclude readonly fields (client cannot set them).
`)
}

Expand Down Expand Up @@ -1737,10 +1757,10 @@ function generateApiOperationsContent(
const typeHelpers = `
type AllOps = keyof operations

/** Response data type for an operation (all fields required). */
/** Response data type (ALL fields required - default). */
export type ApiResponse<K extends AllOps> = _ApiResponse<operations, K>
/** Response data type - only \`readonly\` fields required. */
export type ApiResponseSafe<K extends AllOps> = _ApiResponseSafe<operations, K>
/** Response data type (only readonly/required fields required - strict mode). */
export type ApiResponseStrict<K extends AllOps> = _ApiResponseStrict<operations, K>
/** Request body type. */
export type ApiRequest<K extends AllOps> = _ApiRequest<operations, K>
/** Path parameters type. */
Expand All @@ -1764,7 +1784,7 @@ import type { operations } from './openapi-types'
import { HttpMethod } from '@qualisero/openapi-endpoint'
import type {
ApiResponse as _ApiResponse,
ApiResponseSafe as _ApiResponseSafe,
ApiResponseStrict as _ApiResponseStrict,
ApiRequest as _ApiRequest,
ApiPathParams as _ApiPathParams,
ApiPathParamsInput as _ApiPathParamsInput,
Expand Down Expand Up @@ -1849,10 +1869,10 @@ function generateApiTypesContent(
.join('\n')

const commonLines = [
` /** Full response type - all fields required. */`,
` export type Response = _ApiResponse<OpenApiOperations, '${id}'>`,
` /** Response type - only \`readonly\` fields required. */`,
` export type SafeResponse = _ApiResponseSafe<OpenApiOperations, '${id}'>`,
` /** Response type - ALL fields required (default). */`,
` export type Response = _ApiResponse<OpenApiOperations, '${id}'>`,
` /** Response type - only readonly/required fields required (strict mode). */`,
` export type StrictResponse = _ApiResponseStrict<OpenApiOperations, '${id}'>`,
]
if (!query) {
commonLines.push(
Expand All @@ -1878,7 +1898,7 @@ function generateApiTypesContent(

import type {
ApiResponse as _ApiResponse,
ApiResponseSafe as _ApiResponseSafe,
ApiResponseStrict as _ApiResponseStrict,
ApiRequest as _ApiRequest,
ApiPathParams as _ApiPathParams,
ApiQueryParams as _ApiQueryParams,
Expand Down Expand Up @@ -1938,19 +1958,34 @@ async function main(): Promise<void> {

// Parse options
let excludePrefix: string | null = '_deprecated' // default
let useStrictResponse = false // default to false

for (let i = 0; i < optionArgs.length; i++) {
if (optionArgs[i] === '--no-exclude') {
excludePrefix = null
} else if (optionArgs[i] === '--exclude-prefix') {
if (optionArgs[i] === '--exclude-prefix') {
if (i + 1 < optionArgs.length) {
excludePrefix = optionArgs[i + 1]
const value = optionArgs[i + 1]
// If value is 'false', treat as no exclusion
if (value === 'false') {
excludePrefix = null
} else {
excludePrefix = value
}
i++ // Skip next arg since we consumed it
} else {
console.error('❌ Error: --exclude-prefix requires a value')
printUsage()
process.exit(1)
}
} else if (optionArgs[i] === '--use-strict-response') {
if (i + 1 < optionArgs.length) {
const value = optionArgs[i + 1]
// Support 'true' or 'false' values
useStrictResponse = value !== 'false'
i++ // Skip next arg since we consumed it
} else {
// Flag without value means true
useStrictResponse = true
}
}
}

Expand All @@ -1968,6 +2003,13 @@ async function main(): Promise<void> {
console.log(`✅ Including all operations (no exclusion filter)`)
}

// Log response typing setting
if (useStrictResponse) {
console.log(`✅ Using ApiResponseStrict (only readonly/required fields required)`)
} else {
console.log(`✅ Using ApiResponse (ALL fields required)`)
}

// Fetch and parse OpenAPI spec once
let openapiContent = await fetchOpenAPISpec(openapiInput)
const openApiSpec: OpenAPISpec = JSON.parse(openapiContent)
Expand All @@ -1986,7 +2028,7 @@ async function main(): Promise<void> {
generateApiSchemas(openapiContent, outputDir, excludePrefix),
generateApiOperationsFile(openApiSpec, outputDir, excludePrefix, schemaEnumNames),
generateApiTypesFile(openApiSpec, outputDir, excludePrefix),
generateApiClientFile(openApiSpec, outputDir, excludePrefix),
generateApiClientFile(openApiSpec, outputDir, excludePrefix, useStrictResponse),
])

console.log('🎉 Code generation completed successfully!')
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type {

// Type extraction utilities (used in generated api-operations.ts / api-client.ts)
ApiResponse,
ApiResponseSafe,
ApiResponseStrict,
ApiRequest,
ApiPathParams,
ApiPathParamsInput,
Expand Down
28 changes: 21 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,10 @@ type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <

type IsReadonly<T, K extends keyof T> = IfEquals<Pick<T, K>, { -readonly [Q in K]: T[K] }, false, true>

type RequireReadonly<T> = {
[K in keyof T as IsReadonly<T, K> extends true ? K : never]-?: T[K]
type RequireReadonlyOrRequired<T> = {
[K in keyof T as IsReadonly<T, K> extends true ? K : undefined extends T[K] ? never : K]-?: T[K]
} & {
[K in keyof T as IsReadonly<T, K> extends false ? K : never]: T[K]
[K in keyof T as IsReadonly<T, K> extends false ? (undefined extends T[K] ? K : never) : never]: T[K]
}

type ExtractResponseData<Ops extends AnyOps, Op extends keyof Ops> = Ops[Op] extends {
Expand All @@ -317,15 +317,29 @@ type ExtractResponseData<Ops extends AnyOps, Op extends keyof Ops> = Ops[Op] ext
: unknown

/**
* Extract the response data type (all fields required).
* @example `ApiResponse<operations, 'getPet'>` → `{ readonly id: string, name: string, ... }`
* Extract response data type (ALL fields required - default behavior).
*
* Used for ALL endpoint responses (GET, POST, PUT, PATCH, DELETE) by default.
* Assumes the API always returns all fields regardless of how they're marked in the spec.
*
* @example `ApiResponse<operations, 'getPet'>` → `{ readonly id: string, name: string, tag: string, status: 'available' | ... }`
*/
export type ApiResponse<Ops extends AnyOps, Op extends keyof Ops> = RequireAll<ExtractResponseData<Ops, Op>>

/**
* Extract the response data type (only readonly fields required).
* Extract response data type (only readonly OR required fields are required - strict mode).
*
* Used for ALL endpoint responses when `--use-strict-response` flag is enabled.
* Only marks fields as required if they are:
* - readonly (server-generated), OR
* - marked as required in the OpenAPI spec
* All other fields remain optional.
*
* @example `ApiResponseStrict<operations, 'getPet'>` → `{ readonly id: string, name: string, tag?: string, status?: 'available' | ... }`
*/
export type ApiResponseSafe<Ops extends AnyOps, Op extends keyof Ops> = RequireReadonly<ExtractResponseData<Ops, Op>>
export type ApiResponseStrict<Ops extends AnyOps, Op extends keyof Ops> = RequireReadonlyOrRequired<
ExtractResponseData<Ops, Op>
>

type Writable<T> = {
-readonly [K in keyof T as IfEquals<Pick<T, K>, { -readonly [Q in K]: T[K] }, false, true> extends false
Expand Down
Loading