···55import { createClient } from '@hey-api/openapi-ts';
66import { describe, expect, it, vi } from 'vitest';
7788-import { getSpecsPath } from '../../../utils';
88+import { getSpecsPath } from '../../../../utils';
991010const __filename = fileURLToPath(import.meta.url);
1111const __dirname = path.dirname(__filename);
···69697070 expect(mutationOptions).toHaveProperty('mutation');
7171 expect(typeof mutationOptions.mutation).toBe('function');
7272- });
7373-7474- it('should respect autoDetectHttpMethod setting', async () => {
7575- const piniaColadaDefault = await setupPiniaColadaTest({
7676- autoDetectHttpMethod: true,
7777- });
7878-7979- // With auto-detection, GET should be query, POST should be mutation
8080- expect(piniaColadaDefault.getPetByIdQuery).toBeDefined(); // GET -> query
8181- expect(piniaColadaDefault.addPetMutation).toBeDefined(); // POST -> mutation
8282- expect(piniaColadaDefault.addPetQuery).toBeUndefined(); // POST should not generate query
8383-8484- const piniaColadaDisabled = await setupPiniaColadaTest({
8585- autoDetectHttpMethod: false,
8686- });
8787-8888- // With auto-detection disabled, both GET and POST should generate queries (legacy behavior)
8989- expect(piniaColadaDisabled.getPetByIdQuery).toBeDefined();
9090- // Note: The legacy behavior test might need adjustment based on actual implementation
9172 });
92739374 it('should respect operation type overrides', async () => {
···66import type { PluginConfigMap } from '../plugins/config';
77import { PluginInstance } from '../plugins/shared/utils/instance';
88import type { PluginNames } from '../plugins/types';
99-import type { StringCase } from '../types/case';
109import type { Config } from '../types/config';
1110import type { Files } from '../types/utils';
1211import type { Logger } from '../utils/logger';
1312import { resolveRef } from '../utils/ref';
1413import type { IR } from './types';
15141616-export interface ContextFile {
1717- /**
1818- * Define casing for identifiers in this file.
1919- */
2020- case?: StringCase;
2121- /**
2222- * Should the exports from this file be re-exported in the index barrel file?
2323- */
2424- exportFromIndex?: boolean;
2525- /**
2626- * Unique file identifier.
2727- */
2828- id: string;
2929- /**
3030- * Relative file path to the output path.
3131- *
3232- * @example
3333- * 'bar/foo.ts'
3434- */
3535- path: string;
3636-}
3737-3815export class IRContext<Spec extends Record<string, any> = any> {
3916 /**
4017 * Configuration for parsing and generating the output. This
···9269 * Create and return a new TypeScript file. Also set the current file context
9370 * to the newly created file.
9471 */
9595- public createFile(file: ContextFile): GeneratedFile {
7272+ public createFile(file: IR.ContextFile): GeneratedFile {
9673 // TODO: parser - handle attempt to create duplicate
9774 const outputParts = file.path.split('/');
9875 const outputDir = path.resolve(
···127104 /**
128105 * Returns a specific file by ID from `files`.
129106 */
130130- public file({ id }: Pick<ContextFile, 'id'>): GeneratedFile | undefined {
107107+ public file({ id }: Pick<IR.ContextFile, 'id'>): GeneratedFile | undefined {
131108 return this.files[id];
132109 }
133110
+84-2
packages/openapi-ts/src/ir/types.d.ts
···33 SecuritySchemeObject,
44 ServerObject,
55} from '../openApi/3.1.x/types/spec';
66-import type { ContextFile as CtxFile, IRContext } from './context';
66+import type { StringCase } from '../types/case';
77+import type { IRContext } from './context';
78import type { IRMediaType } from './mediaType';
89910interface IRBodyObject {
···2223 parameters?: Record<string, IRParameterObject>;
2324 requestBodies?: Record<string, IRRequestBodyObject>;
2425 schemas?: Record<string, IRSchemaObject>;
2626+}
2727+2828+interface IRContextFile {
2929+ /**
3030+ * Define casing for identifiers in this file.
3131+ */
3232+ case?: StringCase;
3333+ /**
3434+ * Should the exports from this file be re-exported in the index barrel file?
3535+ */
3636+ exportFromIndex?: boolean;
3737+ /**
3838+ * Unique file identifier.
3939+ */
4040+ id: string;
4141+ /**
4242+ * Relative file path to the output path.
4343+ *
4444+ * @example
4545+ * 'bar/foo.ts'
4646+ */
4747+ path: string;
4848+}
4949+5050+interface IRHooks {
5151+ /**
5252+ * Hooks specifically for overriding operations behavior.
5353+ *
5454+ * Use these to classify operations, decide which outputs to generate,
5555+ * or apply custom behavior to individual operations.
5656+ */
5757+ operations?: {
5858+ /**
5959+ * Classify the given operation into one or more kinds.
6060+ *
6161+ * Each kind determines how we treat the operation (e.g., generating queries or mutations).
6262+ *
6363+ * **Default behavior:**
6464+ * - GET → 'query'
6565+ * - DELETE, PATCH, POST, PUT → 'mutation'
6666+ *
6767+ * **Resolution order:**
6868+ * 1. If `isQuery` or `isMutation` returns `true` or `false`, that overrides `getKind`.
6969+ * 2. If `isQuery` or `isMutation` returns `undefined`, the result of `getKind` is used.
7070+ *
7171+ * @param operation - The operation object to classify.
7272+ * @returns An array containing one or more of 'query' or 'mutation'.
7373+ */
7474+ getKind?: (
7575+ operation: IROperationObject,
7676+ ) => ReadonlyArray<'mutation' | 'query'>;
7777+ /**
7878+ * Check if the given operation should be treated as a mutation.
7979+ *
8080+ * This affects which outputs are generated for the operation.
8181+ *
8282+ * **Default behavior:** DELETE, PATCH, POST, and PUT operations are treated as mutations.
8383+ *
8484+ * **Resolution order:** If this returns `true` or `false`, it overrides `getKind`.
8585+ * If it returns `undefined`, `getKind` is used instead.
8686+ *
8787+ * @param operation - The operation object to check.
8888+ * @returns true if the operation is a mutation, false otherwise, or undefined to fallback to `getKind`.
8989+ */
9090+ isMutation?: (operation: IROperationObject) => boolean | undefined;
9191+ /**
9292+ * Check if the given operation should be treated as a query.
9393+ *
9494+ * This affects which outputs are generated for the operation.
9595+ *
9696+ * **Default behavior:** GET operations are treated as queries.
9797+ *
9898+ * **Resolution order:** If this returns `true` or `false`, it overrides `getKind`.
9999+ * If it returns `undefined`, `getKind` is used instead.
100100+ *
101101+ * @param operation - The operation object to check.
102102+ * @returns true if the operation is a query, false otherwise, or undefined to fallback to `getKind`.
103103+ */
104104+ isQuery?: (operation: IROperationObject) => boolean | undefined;
105105+ };
25106}
2610727108interface IROperationObject {
···221302 export type BodyObject = IRBodyObject;
222303 export type ComponentsObject = IRComponentsObject;
223304 export type Context<Spec extends Record<string, any> = any> = IRContext<Spec>;
224224- export type ContextFile = CtxFile;
305305+ export type ContextFile = IRContextFile;
306306+ export type Hooks = IRHooks;
225307 export type Model = IRModel;
226308 export type OperationObject = IROperationObject;
227309 export type ParameterObject = IRParameterObject;
···55import type { Plugin } from '../../types';
66import type { WalkEvent, WalkEventType } from '../types/instance';
7788+const defaultGetKind: Required<Required<IR.Hooks>['operations']>['getKind'] = (
99+ operation,
1010+) => {
1111+ switch (operation.method) {
1212+ case 'delete':
1313+ case 'patch':
1414+ case 'post':
1515+ case 'put':
1616+ return ['mutation'];
1717+ case 'get':
1818+ return ['query'];
1919+ default:
2020+ return [];
2121+ }
2222+};
2323+824export class PluginInstance<T extends Plugin.Types = Plugin.Types> {
99- public api: T['api'];
1010- public config: Omit<T['resolvedConfig'], 'name' | 'output'>;
1111- public context: IR.Context;
1212- public dependencies: Required<Plugin.Config<T>>['dependencies'] = [];
2525+ api: T['api'];
2626+ config: Omit<T['resolvedConfig'], 'name' | 'output'>;
2727+ context: IR.Context;
2828+ dependencies: Required<Plugin.Config<T>>['dependencies'] = [];
1329 private handler: Plugin.Config<T>['handler'];
1414- public name: T['resolvedConfig']['name'];
1515- public output: Required<T['config']>['output'];
3030+ name: T['resolvedConfig']['name'];
3131+ output: Required<T['config']>['output'];
1632 /**
1733 * The package metadata and utilities for the current context, constructed
1834 * from the provided dependencies. Used for managing package-related
1935 * information such as name, version, and dependency resolution during
2036 * code generation.
2137 */
2222- public package: IR.Context['package'];
3838+ package: IR.Context['package'];
23392424- public constructor(
4040+ constructor(
2541 props: Pick<
2642 Required<Plugin.Config<T>>,
2743 'config' | 'dependencies' | 'handler'
···4258 this.package = props.context.package;
4359 }
44604545- public createFile(file: IR.ContextFile) {
6161+ createFile(file: IR.ContextFile) {
4662 return this.context.createFile({
4763 exportFromIndex: this.config.exportFromIndex,
4864 ...file,
···7187 * }
7288 * });
7389 */
7474- public forEach<T extends WalkEventType = WalkEventType>(
9090+ forEach<T extends WalkEventType = WalkEventType>(
7591 ...args: [
7692 ...events: ReadonlyArray<T>,
7793 callback: (event: WalkEvent<T>) => void,
···216232 * @param name Plugin name as defined in the configuration.
217233 * @returns The plugin instance if found, undefined otherwise.
218234 */
219219- public getPlugin<T extends keyof PluginConfigMap>(
235235+ getPlugin<T extends keyof PluginConfigMap>(
220236 name: T,
221237 ): T extends any ? PluginInstance<PluginConfigMap[T]> | undefined : never {
222238 return this.context.plugins[name] as any;
223239 }
224240241241+ hooks = {
242242+ operation: {
243243+ isMutation: (operation: IR.OperationObject): boolean => {
244244+ const isMutation =
245245+ this.config['~hooks']?.operations?.isMutation ??
246246+ this.context.config.parser.hooks.operations?.isMutation;
247247+ const isMutationResult = isMutation?.(operation);
248248+ if (isMutationResult !== undefined) {
249249+ return isMutationResult;
250250+ }
251251+ const getKind =
252252+ this.config['~hooks']?.operations?.getKind ??
253253+ this.context.config.parser.hooks.operations?.getKind ??
254254+ defaultGetKind;
255255+ return getKind(operation).includes('mutation');
256256+ },
257257+ isQuery: (operation: IR.OperationObject): boolean => {
258258+ const isQuery =
259259+ this.config['~hooks']?.operations?.isQuery ??
260260+ this.context.config.parser.hooks.operations?.isQuery;
261261+ const isQueryResult = isQuery?.(operation);
262262+ if (isQueryResult !== undefined) {
263263+ return isQueryResult;
264264+ }
265265+ const getKind =
266266+ this.config['~hooks']?.operations?.getKind ??
267267+ this.context.config.parser.hooks.operations?.getKind ??
268268+ defaultGetKind;
269269+ return getKind(operation).includes('query');
270270+ },
271271+ },
272272+ };
273273+225274 /**
226275 * Executes plugin's handler function.
227276 */
228228- public async run() {
277277+ async run() {
229278 await this.handler({ plugin: this });
230279 }
231280}
+13
packages/openapi-ts/src/plugins/types.d.ts
···11import type { ValueToObject } from '../config/utils/config';
22import type { Package } from '../config/utils/package';
33+import type { IR } from '../ir/types';
34import type { OpenApi as LegacyOpenApi } from '../openApi';
45import type { Client as LegacyClient } from '../types/client';
56import type { Files } from '../types/utils';
···6162 exportFromIndex?: boolean;
6263 name: AnyPluginName;
6364 output?: string;
6565+ /**
6666+ * Optional hooks to override default plugin behavior.
6767+ *
6868+ * Use these to classify resources, control which outputs are generated,
6969+ * or provide custom behavior for specific resources.
7070+ */
7171+ '~hooks'?: IR.Hooks;
6472};
65736674/**
···116124 */
117125 name: any;
118126 };
127127+128128+ /**
129129+ * Generic wrapper for plugin hooks.
130130+ */
131131+ export type Hooks = Pick<BaseConfig, '~hooks'>;
119132120133 export interface Name<Name extends PluginNames> {
121134 name: Name;
+15
packages/openapi-ts/src/types/parser.d.ts
···11+import type { IR } from '../ir/types';
12import type {
23 OpenApiMetaObject,
34 OpenApiOperationObject,
···1617 * to plugins.
1718 */
1819 filters?: Filters;
2020+ /**
2121+ * Optional hooks to override default plugin behavior.
2222+ *
2323+ * Use these to classify resources, control which outputs are generated,
2424+ * or provide custom behavior for specific resources.
2525+ */
2626+ hooks?: IR.Hooks;
1927 /**
2028 * Pagination configuration.
2129 */
···178186 * to plugins.
179187 */
180188 filters?: Filters;
189189+ /**
190190+ * Optional hooks to override default plugin behavior.
191191+ *
192192+ * Use these to classify resources, control which outputs are generated,
193193+ * or provide custom behavior for specific resources.
194194+ */
195195+ hooks: IR.Hooks;
181196 /**
182197 * Pagination configuration.
183198 */
+9
packages/openapi-ts/src/types/utils.d.ts
···11import type { GeneratedFile } from '../generate/file';
2233+/** Recursively make all non-function properties optional */
44+export type DeepPartial<T> = {
55+ [K in keyof T]?: T[K] extends (...args: any[]) => any
66+ ? T[K]
77+ : T[K] extends object
88+ ? DeepPartial<T[K]>
99+ : T[K];
1010+};
1111+312export type Files = Record<string, GeneratedFile>;