Declarative Service Requests – @kosServiceRequest Decorator

Overview

The @kosServiceRequest decorator enables declarative HTTP service integration in KOS models, automatically handling request lifecycle, caching, error management, and baseline-delta synchronization. This pattern eliminates manual service calls and provides type-safe, automatic data loading tied to model lifecycle.

Important: The @kosServiceRequest decorator is app-specific and must be imported from versioned service files (either SDK packages like @kosdev-code/kos-ui-sdk or local services-index). Each app’s decorator is fully typed for that app’s OpenAPI specification, providing complete IntelliSense for paths, parameters, and responses.

Quick Start: Progressive Examples

Level 1: Automatic Data Loading (Lifecycle)

The simplest use case: automatically load data when your model becomes ready.

import { kosModel, IKosDataModel, KosModelType, DependencyLifecycle } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest } = ICE_APP;
@kosModel()
export class RecipeList implements IKosDataModel {
  modelType = KosModelType.Standard;
  recipes = [];
  @kosServiceRequest({
    path: '/api/ice/recipes',
    lifecycle: DependencyLifecycle.READY
  })
  async handleRecipesLoaded(data): Promise<void> {
    this.recipes = data.recipes;
  }
}

What happens:

  • When the model reaches the READY lifecycle phase, the decorator automatically sends a GET request to /api/ice/recipes
  • When the response arrives, handleRecipesLoaded is called with the data
  • Your model updates with the latest recipes

Key concepts introduced:

  • Basic decorator syntax
  • path parameter (the API endpoint)
  • lifecycle parameter (when to execute)
  • Handler method receives response data

Level 2: Load Specific Resource (Path Params)

Load data for a specific resource using path parameters.

import { kosModel, IKosDataModel, KosModelType, createPropKey, DependencyLifecycle } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest } = ICE_APP;
const PROP_MACHINE_ID = createPropKey<IceMachine>('machineId');
@kosModel()
export class IceMachine implements IKosDataModel {
  modelType = KosModelType.Standard;
  machineId: string;
  machineData = null;
  constructor(machineId: string) {
    this.machineId = machineId;
  }
  @kosServiceRequest({
    path: '/api/ice/machines/{machineId}',
    pathParams: { machineId: PROP_MACHINE_ID },
    lifecycle: DependencyLifecycle.READY
  })
  async handleMachineLoaded(data): Promise<void> {
    this.machineData = data;
  }
}

What happens:

  • The {machineId} placeholder in the path is replaced with the actual this.machineId value
  • For a machine with ID “ICE-001”, the request goes to /api/ice/machines/ICE-001

Key concepts introduced:

  • pathParams for dynamic path segments
  • createPropKey for type-safe property references
  • Path parameter substitution (e.g., {machineId})

Level 3: Submit Data (POST with Body)

Send data to the server using POST requests.

import { kosModel, IKosDataModel, KosModelType } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest } = ICE_APP;
@kosModel()
export class RecipeCreator implements IKosDataModel {
  modelType = KosModelType.Standard;
  newRecipeName = '';
  temperature = 0;
  recipeId = null;
  @kosServiceRequest({
    path: '/api/ice/recipes',
    method: 'post',
    bodyParams: function(this: RecipeCreator) {
      return {
        name: this.newRecipeName,
        temperature: this.temperature
      };
    }
  })
  async createRecipe(params: {}, ctx: KosExecutionContext): Promise<void> {
    const [error, data] = await ctx.$request();
    if (error) {
      console.error('Failed to create recipe:', error);
      return;
    }
    console.log('Created recipe:', data.id);
    this.recipeId = data.id;
  }
  // Called from UI or other methods
  async submitNewRecipe(name: string, temp: number): Promise<void> {
    this.newRecipeName = name;
    this.temperature = temp;
    await this.createRecipe({}, {} as KosExecutionContext);
  }
}

What happens:

  • When createRecipe is called (manually, not via lifecycle), the decorator injects ctx.$request
  • The bodyParams function builds the request body from current model state
  • Calling ctx.$request() executes the POST and returns [error, data] tuple
  • The method handles the response and updates model state

Key concepts introduced:

  • method: 'post' for POST requests
  • bodyParams function to build request body dynamically
  • Manual invocation with ctx.$request() pattern
  • Error-first tuple response handling

Level 4: Filter with Query Parameters

Use query parameters for filtering, pagination, or search.

import { kosModel, IKosDataModel, KosModelType, DependencyLifecycle } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest } = ICE_APP;
@kosModel()
export class BatchViewer implements IKosDataModel {
  modelType = KosModelType.Standard;
  pageSize = 50;
  currentPage = 0;
  filterStatus = 'active';
  batches = [];
  @kosServiceRequest({
    path: '/api/ice/batches',
    queryParams: function(this: BatchViewer) {
      return {
        limit: this.pageSize,
        offset: this.currentPage * this.pageSize,
        status: this.filterStatus
      };
    },
    lifecycle: DependencyLifecycle.READY
  })
  async handleBatchesLoaded(data): Promise<void> {
    this.batches = data.batches;
  }
}

What happens:

  • Query parameters are appended to the URL: /api/ice/batches?limit=50&offset=0&status=active
  • The queryParams function computes values dynamically from model state
  • Changing currentPage or filterStatus allows re-triggering with new parameters

Key concepts introduced:

  • queryParams for URL query string parameters
  • Dynamic parameter computation
  • Pagination and filtering patterns

Level 5: Transform Response Data

Transform API response data into your preferred model format.

import { kosModel, IKosDataModel, KosModelType, DependencyLifecycle } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest } = ICE_APP;
interface ModelRecipe {
  id: string;
  name: string;
  created: Date;
}
@kosModel()
export class RecipeManager implements IKosDataModel {
  modelType = KosModelType.Standard;
  recipes: ModelRecipe[] = [];
  @kosServiceRequest({
    path: '/api/ice/recipes',
    transform: (response) => {
      return response.recipes.map(r => ({
        id: r.recipe_id,
        name: r.display_name,
        created: new Date(r.created_timestamp)
      }));
    },
    lifecycle: DependencyLifecycle.READY
  })
  async handleRecipesLoaded(transformedData: ModelRecipe[]): Promise<void> {
    this.recipes = transformedData;
  }
}

What happens:

  • The transform function runs before the handler is called
  • API field names (recipe_id, display_name) are mapped to model names (id, name)
  • Timestamp strings are converted to Date objects
  • Handler receives already-transformed data

Key concepts introduced:

  • transform function for data transformation
  • Field name mapping (API format → model format)
  • Type conversion (string → Date)
  • Separation of API types from model types

Working with Lifecycles

Service requests execute at specific model lifecycle phases. Understanding when to use each phase is critical for proper initialization order.

enum DependencyLifecycle {
  CONSTRUCT = 'construct',  // Model construction (rare)
  INIT = 'init',           // Model initialization (common)
  READY = 'ready',         // After init, before activate (most common)
  ACTIVATE = 'activate'    // UI activation phase
}

Multiple Lifecycle Requests

Load different data at different lifecycle phases to ensure proper dependency order:

import { kosModel, IKosDataModel, KosModelType, DependencyLifecycle } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_APP_CONFIG: ApiPath = '/api/ice/config';
const PATH_ICE_STATUS: ApiPath = '/api/ice/machine/status';
type AppConfigResponse = ApiResponse<typeof PATH_APP_CONFIG, 'get'>;
type IceStatusResponse = ApiResponse<typeof PATH_ICE_STATUS, 'get'>;
@kosModel()
export class IceMachineManager implements IKosDataModel {
  modelType = KosModelType.Standard;
  config: AppConfigResponse | null = null;
  status: IceStatusResponse | null = null;
  // Load configuration early during INIT
  @kosServiceRequest({
    path: PATH_APP_CONFIG,
    lifecycle: DependencyLifecycle.INIT
  })
  async handleConfigLoaded(data: AppConfigResponse): Promise<void> {
    this.config = data;
  }
  // Load status during READY (after config is available)
  @kosServiceRequest({
    path: PATH_ICE_STATUS,
    lifecycle: DependencyLifecycle.READY
  })
  async handleStatusLoaded(data: IceStatusResponse): Promise<void> {
    this.status = data;
  }
}

Best practices:

  • INIT: Core configuration data needed before other operations
  • READY: Most service requests (default, recommended)
  • ACTIVATE: Data needed only when UI is active

Response Caching

Control how long responses are cached to optimize performance and reduce unnecessary network requests.

Cache Retention Strategies

import { kosModel, IKosDataModel, KosModelType, ResponseRetention } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath } = ICE_APP;
const PATH_ONE_TIME_DATA: ApiPath = '/api/ice/onetime';
const PATH_MACHINE_STATUS: ApiPath = '/api/ice/machine/status';
const PATH_CACHED_DATA: ApiPath = '/api/ice/cached';
const PATH_CONFIG: ApiPath = '/api/ice/config';
@kosModel()
export class CachingModel implements IKosDataModel {
  modelType = KosModelType.Standard;
  // IMMEDIATE - Clear after handler completes
  @kosServiceRequest({
    path: PATH_ONE_TIME_DATA,
    cache: { retention: ResponseRetention.IMMEDIATE }
  })
  async handleOneTimeData(data: any): Promise<void> { }
  // SINGLE - Keep until next request (default)
  @kosServiceRequest({
    path: PATH_MACHINE_STATUS,
    cache: { retention: ResponseRetention.SINGLE }
  })
  async handleStatus(data: any): Promise<void> { }
  // TTL - Time-based expiration
  @kosServiceRequest({
    path: PATH_CACHED_DATA,
    cache: {
      retention: ResponseRetention.TTL,
      ttl: 5 * 60 * 1000  // 5 minutes
    }
  })
  async handleCachedData(data: any): Promise<void> { }
  // PERMANENT - Keep for model lifetime
  @kosServiceRequest({
    path: PATH_CONFIG,
    cache: { retention: ResponseRetention.PERMANENT }
  })
  async handleConfig(data: any): Promise<void> { }
}

TTL Extension Pattern

Extend TTL on refresh instead of re-fetching for expensive operations:

import { kosModel, IKosDataModel, KosModelType, ResponseRetention } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath } = ICE_APP;
const PATH_EXPENSIVE_OPERATION: ApiPath = '/api/ice/analytics/compute';
@kosModel()
export class AnalyticsModel implements IKosDataModel {
  modelType = KosModelType.Standard;
  @kosServiceRequest({
    path: PATH_EXPENSIVE_OPERATION,
    cache: {
      retention: ResponseRetention.TTL,
      ttl: 10 * 60 * 1000,      // 10 minutes
      extendOnRefresh: true      // Extend TTL instead of re-fetching
    }
  })
  async handleAnalyticsData(data: any): Promise<void> {
    // If cached data exists and hasn't expired,
    // TTL is extended without re-fetching
  }
}

Creating Child Models (iterateOver)

Create child models from array responses using the container pattern:

import {
  kosModel,
  kosContainerAware,
  KosContainerAware,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle
} from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_RECIPES: ApiPath = '/api/ice/recipes';
type RecipesResponse = ApiResponse<typeof PATH_RECIPES, 'get'>;
interface RecipeContainerImpl extends KosContainerAware<RecipeModel> {}
@kosModel()
@kosContainerAware<RecipeModel>()
export class RecipeContainerImpl implements IKosDataModel {
  modelType = KosModelType.Container;
  @kosServiceRequest({
    path: PATH_RECIPES,
    lifecycle: DependencyLifecycle.READY,
    iterateOver: 'recipes',
    modelFactory: RecipeModel,  // Registration object (NOT .factory)
    idExtractor: (item) => item.id,  // Extract ID from mapped options
    mappings: [
      { from: 'recipe_id', to: 'id' },
      { from: 'display_name', to: 'name' }
    ]
  })
  async handleRecipesLoaded(recipes: RecipeModel[]): Promise<void> {
    // Framework creates RecipeModel instances using:
    // 1. mappings transform API data: { recipe_id, display_name } -> { id, name }
    // 2. idExtractor gets ID from mapped options
    // 3. Registration object creates model: RecipeModel.instance(id).options({ id, name }).build()
    // 4. Automatically adds models to this container
  }
}

Key Points:

  • modelFactory takes the registration object (e.g., RecipeModel), NOT a factory function
  • Framework uses builder pattern: Model.instance(id).options(opts).build()
  • mappings transform API fields into valid model options
  • idExtractor gets ID from the mapped options (after mappings applied)
  • Without modelFactory, returns array of plain objects with mappings applied

Alternative: Use transform function instead of mappings

@kosServiceRequest({
  path: PATH_RECIPES,
  iterateOver: 'recipes',
  modelFactory: RecipeModel,
  idExtractor: (item) => item.id,
  transform: (response: RecipesResponse) => {
    // Transform each recipe item into model options
    return response.recipes.map(item => ({
      id: item.recipe_id,
      name: item.display_name,
      calories: item.metadata?.calories || 0,
      isAvailable: !item.sold_out
    }));
  }
})

Baseline-Delta Synchronization ⭐

The Problem

When initializing models that use both HTTP baseline requests (@kosServiceRequest) and WebSocket delta events (@kosTopicHandler), race conditions can occur:

T0:   HTTP Request sent (baseline recipes list)
T100: WebSocket event arrives (recipe R4 quantity updated)
T200: HTTP Response received (baseline recipes list)

If the WebSocket event handler runs before the baseline loads, the update is lost when the baseline overwrites the model state.

The Solution: requiresBaseline

The requiresBaseline parameter on @kosTopicHandler ensures delta events wait for baseline data to load:

import {
  kosModel,
  kosTopicHandler,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle,
  ReplayStrategy
} from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_RECIPES: ApiPath = '/api/ice/recipes';
const TOPIC_RECIPE_UPDATE = '/ice/recipes/update';
type RecipesResponse = ApiResponse<typeof PATH_RECIPES, 'get'>;
interface RecipeUpdatePayload {
  id: string;
  quantity: number;
}
interface Recipe {
  id: string;
  quantity: number;
}
@kosModel()
export class RecipeContainer implements IKosDataModel {
  modelType = KosModelType.Container;
  recipes: Map<string, Recipe> = new Map();
  // Step 1: Load baseline data
  @kosServiceRequest({
    path: PATH_RECIPES,
    lifecycle: DependencyLifecycle.READY
  })
  async handleRecipesLoaded(data: RecipesResponse): Promise<void> {
    data.recipes.forEach(recipe => {
      this.recipes.set(recipe.id, recipe);
    });
  }
  // Step 2: Handle delta events with baseline dependency
  @kosTopicHandler({
    topic: TOPIC_RECIPE_UPDATE,
    websocket: true,
    lifecycle: DependencyLifecycle.READY,
    requiresBaseline: {
      path: PATH_RECIPES,
      method: 'get',
      replayStrategy: ReplayStrategy.AFTER_REQUEST
    }
  })
  handleRecipeUpdate(payload: RecipeUpdatePayload): void {
    const recipe = this.recipes.get(payload.id);
    if (recipe) {
      recipe.quantity = payload.quantity;
    }
  }
}

Replay Strategies

The replayStrategy parameter controls which queued events are replayed after the baseline loads:

enum ReplayStrategy {
  // Replay all queued events (for state machines, audit trails)
  ALL = 'all',
  // Replay events received >= baseline request time (default, recommended)
  // Best for: High-frequency idempotent updates
  AFTER_REQUEST = 'after-request',
  // Replay events received >= baseline response time
  // Best for: Low-frequency config where baseline likely includes changes
  AFTER_RESPONSE = 'after-response',
  // Discard all queued events (baseline is complete source of truth)
  NONE = 'none'
}

How It Works

T0:   @kosServiceRequest sends HTTP baseline request
      └─> Framework captures requestedAt = Date.now()
T100: @kosTopicHandler subscriptions register
      └─> requiresBaseline: { path, replayStrategy }
T150: Delta events arrive (model not ready OR baseline not ready)
      └─> Events queued with receivedAt = Date.now()
T300: Baseline HTTP response arrives
      └─> Framework captures receivedAt = Date.now()
      └─> Baseline handler executes
      └─> Framework triggers queue flush with temporal filtering
          └─> ReplayStrategy.AFTER_REQUEST:
              - Only replay events where receivedAt >= requestedAt
              - Discard events that arrived before baseline request

Multiple Baseline Dependencies

Handle multiple baseline requests with separate delta handlers:

import {
  kosModel,
  kosTopicHandler,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle,
  ReplayStrategy
} from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_RECIPES: ApiPath = '/api/ice/recipes';
const PATH_INVENTORY: ApiPath = '/api/ice/inventory';
const TOPIC_RECIPE_UPDATE = '/ice/recipes/update';
const TOPIC_INVENTORY_UPDATE = '/ice/inventory/update';
type RecipesResponse = ApiResponse<typeof PATH_RECIPES, 'get'>;
type InventoryResponse = ApiResponse<typeof PATH_INVENTORY, 'get'>;
interface RecipeUpdatePayload {
  id: string;
  quantity: number;
}
interface InventoryUpdatePayload {
  id: string;
  available: boolean;
}
interface Recipe {
  id: string;
  quantity: number;
}
interface InventoryItem {
  id: string;
  available: boolean;
}
@kosModel()
export class IceSystemManager implements IKosDataModel {
  modelType = KosModelType.Standard;
  recipes: Map<string, Recipe> = new Map();
  inventory: Map<string, InventoryItem> = new Map();
  // Baseline 1: Recipes
  @kosServiceRequest({
    path: PATH_RECIPES,
    lifecycle: DependencyLifecycle.READY
  })
  async handleRecipesLoaded(data: RecipesResponse): Promise<void> {
    data.recipes.forEach(recipe => this.recipes.set(recipe.id, recipe));
  }
  // Baseline 2: Inventory
  @kosServiceRequest({
    path: PATH_INVENTORY,
    lifecycle: DependencyLifecycle.READY
  })
  async handleInventoryLoaded(data: InventoryResponse): Promise<void> {
    data.items.forEach(item => this.inventory.set(item.id, item));
  }
  // Delta handler 1: Depends on recipes baseline
  @kosTopicHandler({
    topic: TOPIC_RECIPE_UPDATE,
    websocket: true,
    requiresBaseline: {
      path: PATH_RECIPES,
      replayStrategy: ReplayStrategy.AFTER_REQUEST
    }
  })
  handleRecipeUpdate(payload: RecipeUpdatePayload): void {
    const recipe = this.recipes.get(payload.id);
    if (recipe) {
      recipe.quantity = payload.quantity;
    }
  }
  // Delta handler 2: Depends on inventory baseline
  @kosTopicHandler({
    topic: TOPIC_INVENTORY_UPDATE,
    websocket: true,
    requiresBaseline: {
      path: PATH_INVENTORY,
      replayStrategy: ReplayStrategy.AFTER_RESPONSE
    }
  })
  handleInventoryUpdate(payload: InventoryUpdatePayload): void {
    const item = this.inventory.get(payload.id);
    if (item) {
      item.available = payload.available;
    }
  }
}

Best practices:

  • Always use requiresBaseline when combining HTTP + WebSocket
  • Choose AFTER_REQUEST for high-frequency updates
  • Choose AFTER_RESPONSE for low-frequency config changes

Advanced Topics

Conditional Execution

Execute requests based on runtime conditions:

import { kosModel, IKosDataModel, KosModelType } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath } = ICE_APP;
const PATH_OPTIONAL_DATA: ApiPath = '/api/ice/optional';
@kosModel()
export class ConditionalLoader implements IKosDataModel {
  modelType = KosModelType.Standard;
  shouldLoadData = true;
  @kosServiceRequest({
    path: PATH_OPTIONAL_DATA,
    condition: (model: ConditionalLoader) => model.shouldLoadData
  })
  async handleOptionalData(data: any): Promise<void> {
    // Only executes if shouldLoadData is true
  }
}

Multiple Service Interfaces

KOS applications interact with multiple API domains, each with its own OpenAPI specification and typed service interface:

Core SDK Services (shipped with SDK packages):

  • KOS_SERVICE (@kosdev-code/kos-ui-sdk) – Device system services (config, logs, device info)
  • DISPENSE_SERVICE (@kosdev-code/kos-dispense-sdk) – Beverage dispensing operations
  • FREESTYLE_SERVICE (@kosdev-code/kos-freestyle-sdk) – Freestyle cartridge management
  • VFS_SERVICE (@kosdev-code/kos-ui-sdk) – Virtual file system operations

Custom Application Services (generated for your app):

  • ICE_APP (from services-index) – Your application-specific APIs
  • CUSTOM_BACKEND (from services-index) – Custom backend integrations

Example: SDK Service for Device Configuration

import {
  kosModel,
  kosTopicHandler,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle,
  ReplayStrategy,
  KOS_SERVICE
} from '@kosdev-code/kos-ui-sdk';
const { kosServiceRequest, type ApiPath, type ApiResponse } = KOS_SERVICE;
const PATH_DEVICE_CONFIG: ApiPath = '/api/kos/config/details/1';
const TOPIC_CONFIG_UPDATE = '/kos/config/update';
type DeviceConfigResponse = ApiResponse<typeof PATH_DEVICE_CONFIG, 'get'>;
interface ConfigUpdatePayload {
  path: string;
  value: any;
}
@kosModel()
export class DeviceConfigManager implements IKosDataModel {
  modelType = KosModelType.Standard;
  config: Map<string, any> = new Map();
  // Load device configuration baseline
  @kosServiceRequest({
    path: PATH_DEVICE_CONFIG,
    lifecycle: DependencyLifecycle.READY
  })
  async handleConfigLoaded(data: DeviceConfigResponse): Promise<void> {
    Object.entries(data).forEach(([path, value]) => {
      this.config.set(path, value);
    });
  }
  // Handle configuration updates with baseline dependency
  @kosTopicHandler({
    topic: TOPIC_CONFIG_UPDATE,
    websocket: true,
    requiresBaseline: {
      path: PATH_DEVICE_CONFIG,
      replayStrategy: ReplayStrategy.AFTER_RESPONSE
    }
  })
  handleConfigUpdate(payload: ConfigUpdatePayload): void {
    this.config.set(payload.path, payload.value);
  }
}

Decision Guide:

Use Custom Service Interfaces (ICE_APP, etc.) when:

  • Building application-specific features with custom APIs
  • Working with backend endpoints unique to your application
  • Implementing business logic specific to your app domain
  • Your OpenAPI spec defines the endpoints you need

Use Core SDK Services (KOS_SERVICE, DISPENSE_SERVICE, etc.) when:

  • Accessing device-level functionality (config, logs, device status)
  • Working with standard beverage dispensing operations
  • Managing Freestyle cartridges or ingredients
  • Using VFS or other standard KOS platform services

Complex Transformations

Use property mappings for complex field transformations:

import { kosModel, IKosDataModel, KosModelType } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_MACHINE_INFO: ApiPath = '/api/ice/machine/info';
type MachineInfoResponse = ApiResponse<typeof PATH_MACHINE_INFO, 'get'>;
interface TransformedMachineInfo {
  name: string;
  serialNumber: string;
  lastMaintenance: Date;
}
@kosModel()
export class MachineInfoModel implements IKosDataModel {
  modelType = KosModelType.Standard;
  @kosServiceRequest({
    path: PATH_MACHINE_INFO,
    mappings: [
      { from: 'machine_name', to: 'name' },
      { from: 'serial_number', to: 'serialNumber' },
      {
        from: 'last_maintenance_timestamp',
        to: 'lastMaintenance',
        transform: (timestamp: number) => new Date(timestamp)
      }
    ]
  })
  async handleMachineInfoLoaded(data: TransformedMachineInfo): Promise<void> {
    Object.assign(this, data);
  }
}

Reference

Complete Configuration Options

interface KosServiceRequestParams {
  // Required
  path: string;                    // API endpoint path
  // Optional - Request Configuration
  method?: 'get' | 'post' | 'put' | 'delete';  // Default: 'get'
  lifecycle?: DependencyLifecycle;  // When to execute (Default: READY)
  // Optional - Dynamic Parameters
  pathParams?: PropKeyTemplate | (() => Record<string, string>);
  queryParams?: PropKeyTemplate | (() => Record<string, any>);
  bodyParams?: () => any;
  // Optional - Response Processing
  transform?: (response: any) => any;
  mappings?: PropertyMapping[];
  iterateOver?: string;            // Create models from array
  // Optional - Caching
  cache?: ResponseCacheConfig;
  // Optional - Conditional Execution
  condition?: (model: any) => boolean;
}

Error Handling Strategies

The framework provides automatic error handling with retry logic:

import { kosModel, IKosDataModel, KosModelType, ResponseRetention } from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_FLAKY_ENDPOINT: ApiPath = '/api/ice/flaky';
type DataResponse = ApiResponse<typeof PATH_FLAKY_ENDPOINT, 'get'>;
@kosModel()
export class DataLoader implements IKosDataModel {
  modelType = KosModelType.Standard;
  @kosServiceRequest({
    path: PATH_FLAKY_ENDPOINT,
    cache: { retention: ResponseRetention.SINGLE }
  })
  async handleData(data: DataResponse): Promise<void> {
    // Handler only called on successful response
    // Errors are automatically logged and can be accessed via:
    // this.getServiceResponse(PATH_FLAKY_ENDPOINT)
  }
}

Migration from Manual Pattern

Before: Manual Service Calls

import { kosModel, IKosDataModel, KosModelType } from '@kosdev-code/kos-ui-sdk';
import { fetchRecipes } from './services/recipe-service';
interface Recipe {
  id: string;
  name: string;
}
@kosModel()
export class RecipeManager implements IKosDataModel {
  modelType = KosModelType.Standard;
  recipes: Recipe[] = [];
  async init(): Promise<void> {
    const [error, data] = await fetchRecipes();
    if (error) {
      console.error('Failed to load recipes:', error);
      return;
    }
    this.recipes = data.recipes;
  }
}

After: Declarative with @kosServiceRequest

import {
  kosModel,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle
} from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_RECIPES: ApiPath = '/api/ice/recipes';
type RecipesResponse = ApiResponse<typeof PATH_RECIPES, 'get'>;
interface Recipe {
  id: string;
  name: string;
}
@kosModel()
export class RecipeManager implements IKosDataModel {
  modelType = KosModelType.Standard;
  recipes: Recipe[] = [];
  @kosServiceRequest({
    path: PATH_RECIPES,
    lifecycle: DependencyLifecycle.READY
  })
  async handleRecipesLoaded(data: RecipesResponse): Promise<void> {
    this.recipes = data.recipes;
  }
}

Complete Example

A comprehensive example combining multiple features:

import {
  kosModel,
  kosTopicHandler,
  KosContainerAware,
  kosContainerAware,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle,
  ResponseRetention,
  ReplayStrategy
} from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_RECIPES: ApiPath = '/api/ice/recipes';
const TOPIC_RECIPE_UPDATE = '/ice/recipes/update';
type RecipesResponse = ApiResponse<typeof PATH_RECIPES, 'get'>;
interface RecipeUpdatePayload {
  recipeId: string;
  newQuantity: number;
}
interface RecipeContainerImpl extends KosContainerAware<RecipeModel> {}
@kosModel()
@kosContainerAware<RecipeModel>()
export class RecipeContainerImpl implements IKosDataModel {
  modelType = KosModelType.Container;
  // Baseline: Load all recipes during READY phase
  @kosServiceRequest({
    path: PATH_RECIPES,
    lifecycle: DependencyLifecycle.READY,
    cache: {
      retention: ResponseRetention.TTL,
      ttl: 5 * 60 * 1000  // Cache for 5 minutes
    },
    iterateOver: 'recipes',
    mappings: [
      { from: 'recipe_id', to: 'id' },
      { from: 'display_name', to: 'name' },
      { from: 'quantity_ml', to: 'quantity' }
    ]
  })
  async handleRecipesLoaded(recipes: RecipeModel[]): Promise<void> {
    // Framework automatically creates child models and adds to container
  }
  // Delta: Handle real-time updates with baseline dependency
  @kosTopicHandler({
    topic: TOPIC_RECIPE_UPDATE,
    websocket: true,
    lifecycle: DependencyLifecycle.READY,
    requiresBaseline: {
      path: PATH_RECIPES,
      method: 'get',
      replayStrategy: ReplayStrategy.AFTER_REQUEST
    },
    transform: (payload: RecipeUpdatePayload) => ({
      id: payload.recipeId,
      quantity: payload.newQuantity
    })
  })
  handleRecipeUpdate(data: { id: string; quantity: number }): void {
    const recipe = this.getModel(data.id);
    if (recipe) {
      recipe.quantity = data.quantity;
    }
  }
}

Appendix: Service Architecture Decision Guide

Understanding KOS Service Architecture

Most KOS applications use both custom application services and core SDK services:

// Custom app models use ICE_APP for business logic
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
// Device integration models use SDK services for platform features
import { KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
const { kosServiceRequest, type ApiPath, type ApiResponse } = KOS_SERVICE;

Typical Application Architecture

Custom Application Layer:

  • Business logic specific to your application
  • Application-specific API endpoints
  • Domain models for your use case
  • Uses ICE_APP, CUSTOM_BACKEND, or other generated service interfaces

Platform Integration Layer:

  • Device-level functionality
  • Standard beverage operations
  • System configuration and monitoring
  • Uses KOS_SERVICE, DISPENSE_SERVICE, FREESTYLE_SERVICE, etc.

Import Guide

For custom application services (most common):

import {
  kosModel,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle
} from '@kosdev-code/kos-ui-sdk';
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;

For SDK core services (device functionality):

import {
  kosModel,
  IKosDataModel,
  KosModelType,
  DependencyLifecycle,
  KOS_SERVICE
} from '@kosdev-code/kos-ui-sdk';
const { kosServiceRequest, type ApiPath, type ApiResponse } = KOS_SERVICE;

Define path constants and import response types:

// For ice app recipes
import { ICE_APP } from '../../../utils/services-index';
const { kosServiceRequest, type ApiPath, type ApiResponse } = ICE_APP;
const PATH_RECIPES: ApiPath = '/api/ice/recipes';
type RecipesResponse = ApiResponse<typeof PATH_RECIPES, 'get'>;

Conclusion

The @kosServiceRequest decorator provides a powerful, declarative approach to HTTP service integration in KOS models. Combined with the baseline-delta race condition solution via requiresBaseline, it ensures reliable, type-safe data synchronization across HTTP and WebSocket channels.

Key takeaways:

  • Start simple: Begin with basic GET requests and add complexity progressively
  • Declarative > Imperative: Define what data you need, not how to fetch it
  • Lifecycle-Aware: Requests execute at the right time automatically
  • Baseline-Delta Safe: Use requiresBaseline to prevent race conditions
  • Type-Safe: Full TypeScript inference from OpenAPI specifications
  • Cache-Smart: Flexible retention strategies for optimal performance
  • Service-Specific: Import from custom services (ICE_APP) for app logic, SDK services for device functionality