Skip to content

@ahoo-wang/fetcher-decorator

The @ahoo-wang/fetcher-decorator package enables declarative API service definitions using TypeScript decorators. Instead of writing imperative HTTP calls, you define API classes with decorator annotations and the framework generates the implementation automatically at runtime.

Source: packages/decorator/src/

Installation

bash
pnpm add @ahoo-wang/fetcher-decorator reflect-metadata

WARNING

reflect-metadata is required as a peer dependency. It must be imported once at your application entry point before using decorators:

typescript
import 'reflect-metadata';

Architecture

mermaid
graph TB
    subgraph sg_1 ["Decorator Layer"]
        API["@api() class decorator"]
        EP["@get/@post/@put/@delete/@patch<br>endpoint decorators"]
        PARAM["@path/@query/@header/@body/@request/@attribute<br>parameter decorators"]
    end

    subgraph sg_2 ["Metadata Layer"]
        AM["ApiMetadata<br>Class-level config"]
        EM["EndpointMetadata<br>Method-level config"]
        PM["ParameterMetadata<br>Argument bindings"]
        FM["FunctionMetadata<br>Merged config"]
    end

    subgraph sg_3 ["Execution Layer"]
        RE["RequestExecutor<br>Builds and runs the request"]
        LIFE["ExecuteLifeCycle<br>beforeExecute / afterExecute"]
        FETCHER["@ahoo-wang/fetcher<br>Interceptor pipeline"]
    end

    API --> AM
    EP --> EM
    PARAM --> PM
    AM --> FM
    EM --> FM
    PM --> FM
    FM --> RE
    RE --> LIFE
    RE --> FETCHER

    style API fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style EP fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style PARAM fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style AM fill:#161b22,stroke:#30363d,color:#e6edf3
    style EM fill:#161b22,stroke:#30363d,color:#e6edf3
    style PM fill:#161b22,stroke:#30363d,color:#e6edf3
    style FM fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style RE fill:#161b22,stroke:#30363d,color:#e6edf3
    style LIFE fill:#161b22,stroke:#30363d,color:#e6edf3
    style FETCHER fill:#161b22,stroke:#30363d,color:#e6edf3

Decorator Execution Flow

When a decorated method is called, the framework executes a well-defined pipeline:

mermaid
sequenceDiagram
autonumber

    participant App as Application
    participant Method as Decorated Method
    participant RE as RequestExecutor
    participant FM as FunctionMetadata
    participant Fetcher as Fetcher
    participant LC as ExecuteLifeCycle
    participant IM as Interceptor Chain

    App->>Method: userService.getUser(123)
    Method->>RE: execute([123])
    RE->>FM: resolveExchangeInit(args)
    FM->>FM: Map @path params to path: {id: 123}
    FM->>FM: Map @query params to query: {...}
    FM->>FM: Map @header params to headers: {...}
    FM->>FM: Map @body to body: {...}
    FM->>FM: Resolve attributes, timeout, fetcher
    FM-->>RE: {request, attributes}
    RE->>Fetcher: resolveExchange(request, options)
    Fetcher-->>RE: FetchExchange

    alt Target implements ExecuteLifeCycle
        RE->>LC: beforeExecute(exchange)
        LC-->>RE: Modified exchange
    end

    RE->>IM: interceptors.exchange(exchange)
    IM-->>RE: Completed exchange

    alt Target implements ExecuteLifeCycle
        RE->>LC: afterExecute(exchange)
    end

    RE->>RE: extractResult()
    RE-->>App: Result (Response, JSON, etc.)

Defining an API Class

The @api Decorator

The @api class decorator sets base path, headers, timeout, and the fetcher instance for all methods in the class. (apiDecorator.ts:232)

typescript
import { api, get, post, put, del, path, query, body, header, autoGeneratedError } from '@ahoo-wang/fetcher-decorator';
import 'reflect-metadata';

interface User {
  id: number;
  name: string;
  email: string;
}

@api('/api/v1/users', {
  headers: { 'X-Api-Version': '1.0' },
  timeout: 10000,
  fetcher: 'api', // Use the 'api' named fetcher
})
class UserService {
  @get('/')
  getUsers(
    @query('page') page: number,
    @query('limit') limit: number,
  ): Promise<User[]> {
    throw autoGeneratedError();
  }

  @get('/:id')
  getUser(@path('id') id: number): Promise<User> {
    throw autoGeneratedError();
  }

  @post('/')
  createUser(@body() user: Omit<User, 'id'>): Promise<User> {
    throw autoGeneratedError();
  }

  @put('/:id')
  updateUser(
    @path('id') id: number,
    @body() user: Partial<User>,
  ): Promise<User> {
    throw autoGeneratedError();
  }

  @del('/:id')
  deleteUser(@path('id') id: number): Promise<void> {
    throw autoGeneratedError();
  }
}

Endpoint Decorators

DecoratorHTTP MethodSource
@get(path?, metadata?)GETendpointDecorator.ts:101
@post(path?, metadata?)POSTendpointDecorator.ts:126
@put(path?, metadata?)PUTendpointDecorator.ts:151
@del(path?, metadata?)DELETEendpointDecorator.ts:176
@patch(path?, metadata?)PATCHendpointDecorator.ts:201
@head(path?, metadata?)HEADendpointDecorator.ts:229
@options(path?, metadata?)OPTIONSendpointDecorator.ts:254

Each endpoint decorator accepts optional MethodEndpointMetadata to override class-level settings:

typescript
@get('/special', {
  headers: { 'X-Special': 'true' },
  timeout: 30000,
  resultExtractor: ResultExtractors.Json,
})
specialEndpoint(): Promise<any> {
  throw autoGeneratedError();
}

Parameter Decorators

Parameter decorators map function arguments to HTTP request components. When no explicit name is provided, the parameter name is automatically extracted from the function signature. (parameterDecorator.ts:199)

DecoratorTypeMaps ToObject Expansion
@path(name?)ParameterType.PATHURL path segmentYes - object keys become path params
@query(name?)ParameterType.QUERYURL query stringYes - object keys become query params
@header(name?)ParameterType.HEADERRequest headersYes - object keys become headers
@body()ParameterType.BODYRequest bodyNo
@request()ParameterType.REQUESTBase request objectNo
@attribute(name?)ParameterType.ATTRIBUTEExchange attributesYes - objects and Maps
mermaid
graph LR
    subgraph sg_1 ["Method Arguments"]
        A1["id: number"]
        A2["filters: object"]
        A3["auth: string"]
        A4["userData: User"]
        A5["request: ParameterRequest"]
        A6["attr: Map"]
    end

    subgraph sg_2 ["Parameter Decorators"]
        D1["@path('id')"]
        D2["@query()"]
        D3["@header('Authorization')"]
        D4["@body()"]
        D5["@request()"]
        D6["@attribute()"]
    end

    subgraph sg_3 ["Request Components"]
        R1["urlParams.path = {id: 123}"]
        R2["urlParams.query = {...filters}"]
        R3["headers['Authorization'] = auth"]
        R4["body = userData"]
        R5["Merged into request"]
        R6["exchange.attributes"]
    end

    A1 --> D1 --> R1
    A2 --> D2 --> R2
    A3 --> D3 --> R3
    A4 --> D4 --> R4
    A5 --> D5 --> R5
    A6 --> D6 --> R6

    style A1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style A2 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style A3 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style A4 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style A5 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style A6 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style D1 fill:#161b22,stroke:#30363d,color:#e6edf3
    style D2 fill:#161b22,stroke:#30363d,color:#e6edf3
    style D3 fill:#161b22,stroke:#30363d,color:#e6edf3
    style D4 fill:#161b22,stroke:#30363d,color:#e6edf3
    style D5 fill:#161b22,stroke:#30363d,color:#e6edf3
    style D6 fill:#161b22,stroke:#30363d,color:#e6edf3
    style R1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style R2 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style R3 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style R4 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style R5 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style R6 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

Object Parameter Expansion

Path, query, and header decorators support object arguments. When an object is passed, its key-value pairs are expanded into individual parameters:

typescript
@api('/api')
class SearchService {
  @get('/search')
  search(
    @query() filters: { limit: number; offset: number; sort: string },
  ): Promise<SearchResult[]> {
    throw autoGeneratedError();
  }
}

// Calling:
service.search({ limit: 10, offset: 20, sort: 'name' });
// => GET /api/search?limit=10&offset=20&sort=name

The @request Decorator

The @request() decorator allows passing a ParameterRequest object for full control over the request. It is merged with endpoint-level configuration, with the parameter request taking precedence. (parameterDecorator.ts:372)

typescript
@api('/api/users')
class UserService {
  @post('/')
  createUser(@request() req: ParameterRequest): Promise<User> {
    throw autoGeneratedError();
  }
}

// Usage:
service.createUser({
  path: '/api/users',
  headers: { 'X-Idempotency-Key': 'abc123' },
  body: { name: 'John' },
  timeout: 30000,
});

The @attribute Decorator

The @attribute() decorator passes data to the exchange attributes, which can be read by any interceptor in the pipeline. (parameterDecorator.ts:408)

typescript
@api('/api/orders')
class OrderService {
  @post('/')
  createOrder(
    @body() order: Order,
    @attribute('tenantId') tenantId: string,
  ): Promise<Order> {
    throw autoGeneratedError();
  }
}

Lifecycle Hooks (ExecuteLifeCycle)

Classes can implement the ExecuteLifeCycle interface to hook into the request execution pipeline. The OpenAI package uses this to dynamically switch result extractors based on whether streaming is enabled. (executeLifeCycle.ts:23)

mermaid
graph TD
    START["Method Called"] --> BUILD["Build FetchExchange"]
    BUILD --> BEFORE["beforeExecute(exchange)"]
    BEFORE --> INTERCEPT["Interceptor Chain"]
    INTERCEPT --> AFTER["afterExecute(exchange)"]
    AFTER --> RESULT["extractResult()"]
    RESULT --> RETURN["Return to caller"]

    style START fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style BUILD fill:#161b22,stroke:#30363d,color:#e6edf3
    style BEFORE fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style INTERCEPT fill:#161b22,stroke:#30363d,color:#e6edf3
    style AFTER fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style RESULT fill:#161b22,stroke:#30363d,color:#e6edf3
    style RETURN fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
typescript
import { api, get, ExecuteLifeCycle, autoGeneratedError } from '@ahoo-wang/fetcher-decorator';
import type { FetchExchange } from '@ahoo-wang/fetcher';

@api('/api/data')
class DataService implements ExecuteLifeCycle {
  async beforeExecute(exchange: FetchExchange): Promise<void> {
    // Add tenant ID from session
    exchange.ensureRequestHeaders()['X-Tenant-Id'] = getTenantId();
    // Add request tracking
    exchange.attributes.set('requestId', crypto.randomUUID());
  }

  async afterExecute(exchange: FetchExchange): Promise<void> {
    // Log completion
    const requestId = exchange.attributes.get('requestId');
    console.log(`Request ${requestId} completed: ${exchange.response?.status}`);
  }

  @get('/items')
  getItems(): Promise<Item[]> {
    throw autoGeneratedError();
  }
}

EndpointReturnType

By default, decorated methods return the extracted result (e.g., parsed JSON). You can change this behavior to return the entire FetchExchange instead. (endpointReturnTypeCapable.ts:14)

ValueDescription
EndpointReturnType.RESULTReturns the extracted result (default)
EndpointReturnType.EXCHANGEReturns the full FetchExchange object
typescript
import { EndpointReturnType } from '@ahoo-wang/fetcher-decorator';

@api('/api/users', { returnType: EndpointReturnType.EXCHANGE })
class UserService {
  @get('/')
  getUsers(): Promise<FetchExchange> {
    throw autoGeneratedError();
  }
}

Metadata Resolution

The FunctionMetadata class merges API-level, endpoint-level, and parameter metadata into a single resolved configuration. Endpoint-level values override API-level values, and parameter decorator values override both. (functionMetadata.ts:43)

mermaid
graph TD
    subgraph sg_1 ["Priority (Highest to Lowest)"]
        P1["Parameter Decorators<br>@path, @query, @header, @body"]
        P2["Endpoint Metadata<br>@get path, metadata arg"]
        P3["API Metadata<br>@api basePath, options"]
    end

    P1 --> MERGE["FunctionMetadata<br>resolveExchangeInit()"]
    P2 --> MERGE
    P3 --> MERGE
    MERGE --> FINAL["Final FetchRequest"]

    style P1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style P2 fill:#161b22,stroke:#30363d,color:#e6edf3
    style P3 fill:#161b22,stroke:#30363d,color:#e6edf3
    style MERGE fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style FINAL fill:#161b22,stroke:#30363d,color:#e6edf3

autoGeneratedError

The autoGeneratedError() function creates a placeholder error that satisfies ESLint's no-unused-vars rule while indicating the method body is replaced at runtime. (generated.ts:41)

typescript
import { autoGeneratedError } from '@ahoo-wang/fetcher-decorator';

@get('/users/:id')
getUser(@path('id') id: number): Promise<User> {
  throw autoGeneratedError(id); // Arguments are accepted but ignored
}

Complete Example

typescript
import 'reflect-metadata';
import { api, get, post, del, path, query, body, header, autoGeneratedError } from '@ahoo-wang/fetcher-decorator';
import { NamedFetcher, ResultExtractors } from '@ahoo-wang/fetcher';

// Setup: create a named fetcher
new NamedFetcher('myApi', {
  baseURL: 'https://api.example.com',
  headers: { 'Accept': 'application/json' },
  timeout: 5000,
});

interface Product {
  id: string;
  name: string;
  price: number;
}

interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
}

@api('/api/v2/products', { fetcher: 'myApi' })
class ProductService {
  @get('/')
  listProducts(
    @query() filters: ProductFilters,
    @query('page') page: number = 1,
    @query('limit') limit: number = 20,
  ): Promise<Product[]> {
    throw autoGeneratedError();
  }

  @get('/:id')
  getProduct(
    @path('id') id: string,
    @header('Accept-Language') locale: string = 'en',
  ): Promise<Product> {
    throw autoGeneratedError();
  }

  @post('/')
  createProduct(@body() product: Omit<Product, 'id'>): Promise<Product> {
    throw autoGeneratedError();
  }

  @del('/:id')
  deleteProduct(@path('id') id: string): Promise<void> {
    throw autoGeneratedError();
  }
}

// Usage
const products = new ProductService();
const items = await products.listProducts({ category: 'electronics' }, 1, 10);

Exported API Summary

ExportTypeSource
apiDecoratorapiDecorator.ts
get, post, put, del, patch, head, optionsDecoratorsendpointDecorator.ts
path, query, header, body, request, attributeDecoratorsparameterDecorator.ts
ApiMetadataInterfaceapiDecorator.ts
EndpointMetadataInterfaceendpointDecorator.ts
ParameterTypeEnumparameterDecorator.ts
ParameterMetadataInterfaceparameterDecorator.ts
FunctionMetadataClassfunctionMetadata.ts
RequestExecutorClassrequestExecutor.ts
ExecuteLifeCycleInterfaceexecuteLifeCycle.ts
EndpointReturnTypeEnumendpointReturnTypeCapable.ts
autoGeneratedErrorFunctiongenerated.ts
buildRequestExecutorFunctionapiDecorator.ts
getParameterNamesFunctionreflection.ts
  • Fetcher (Core) - The HTTP client that decorators delegate to
  • OpenAI - Real-world example using decorators with ExecuteLifeCycle
  • Generator - Auto-generates decorator-based API classes from OpenAPI specs
  • Wow - DDD/CQRS decorator-based clients
  • Packages Overview - All packages in the ecosystem

Released under the Apache License 2.0.