Files
Lorenz Hilpert 2b5da00249 feat(checkout): add reward order confirmation feature with schema migrations
- Add new reward-order-confirmation feature library with components and store
- Implement checkout completion orchestrator service for order finalization
- Migrate checkout/oms/crm models to Zod schemas for better type safety
- Add order creation facade and display order schemas
- Update shopping cart facade with order completion flow
- Add comprehensive tests for shopping cart facade
- Update routing to include order confirmation page
2025-10-21 14:28:52 +02:00
..

@isa/core/config

A lightweight, type-safe configuration management system for Angular applications with runtime validation and nested object access.

Overview

The Core Config library provides a simple yet powerful configuration service for Angular applications. It enables type-safe access to application configuration data with Zod schema validation, supports nested object navigation with dot notation, and integrates seamlessly with Angular's dependency injection system. The library is designed for runtime configuration that needs to be injected at application startup from external sources like environment files or remote configuration servers.

Table of Contents

Features

  • Type-safe configuration - Zod schema validation for runtime safety
  • Nested object access - Dot notation for deep property paths
  • Dependency injection - Angular injection token integration
  • Flexible data structure - Support for primitives, arrays, and objects
  • Array coercion - Automatic handling of string or array paths
  • Runtime validation - Validate configuration at runtime with schemas
  • Lightweight - Minimal dependencies, simple implementation
  • JSON-compatible - Works with standard JSON configuration data

Quick Start

1. Provide Configuration Data

import { ApplicationConfig } from '@angular/core';
import { CONFIG_DATA } from '@isa/core/config';

const appConfig = {
  api: {
    baseUrl: 'https://api.example.com',
    timeout: 30000,
    retries: 3
  },
  features: {
    enableBeta: false,
    maxUploadSize: 10485760
  },
  branding: {
    appName: 'My Application',
    theme: 'light'
  }
};

export const config: ApplicationConfig = {
  providers: [
    {
      provide: CONFIG_DATA,
      useValue: appConfig
    }
  ]
};

2. Inject and Use Config Service

import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

@Component({
  selector: 'app-root',
  template: '...'
})
export class AppComponent {
  #config = inject(Config);

  // Access with validation
  apiConfig = this.#config.get(
    'api',
    z.object({
      baseUrl: z.string().url(),
      timeout: z.number().positive(),
      retries: z.number().int().min(0)
    })
  );

  // Access nested property
  appName = this.#config.get('branding.appName', z.string());

  ngOnInit() {
    console.log(`API URL: ${this.apiConfig.baseUrl}`);
    console.log(`App name: ${this.appName}`);
  }
}

3. Access Without Validation (Deprecated)

// Not recommended - use Zod schema instead
const rawValue = this.#config.get('api.baseUrl');  // any type

Core Concepts

Configuration Data Structure

The configuration data must be JSON-compatible:

type JsonPrimitive = string | number | boolean | null | undefined;

type JsonValue =
  | Array<JsonPrimitive>
  | Record<string, JsonPrimitive>
  | JsonPrimitive;

Supported Types:

  • Primitives: string, number, boolean, null, undefined
  • Objects: Records with primitive values
  • Arrays: Arrays of primitives

Example Configuration:

const config = {
  // Primitives
  appName: 'ISA Frontend',
  port: 8080,
  debugMode: true,

  // Nested objects
  database: {
    host: 'localhost',
    port: 5432,
    name: 'mydb'
  },

  // Arrays
  allowedOrigins: ['http://localhost:4200', 'https://example.com'],

  // Nested with arrays
  features: {
    enabled: ['auth', 'reporting', 'analytics']
  }
};

Dot Notation Access

Access nested properties using dot notation:

// Direct property
this.#config.get('appName', z.string());

// Nested property
this.#config.get('database.host', z.string());

// Deep nesting
this.#config.get('features.beta.settings.maxUsers', z.number());

Path Resolution:

// String path
'database.host'    config.database.host

// Array path (equivalent)
['database', 'host']    config.database.host

// Mixed array path
['features', 'beta', 'settings']    config.features.beta.settings

Zod Schema Validation

All configuration access should use Zod schemas for type safety:

import { z } from 'zod';

// Simple primitive
const port = this.#config.get('port', z.number());

// Object with validation
const dbConfig = this.#config.get(
  'database',
  z.object({
    host: z.string().min(1),
    port: z.number().int().positive(),
    name: z.string()
  })
);

// Array with validation
const origins = this.#config.get(
  'allowedOrigins',
  z.array(z.string().url())
);

// Optional values
const optionalFeature = this.#config.get(
  'features.experimental',
  z.string().optional()
);

// Default values
const timeout = this.#config.get(
  'api.timeout',
  z.number().default(30000)
);

API Reference

CONFIG_DATA

Injection token for providing configuration data.

Type: InjectionToken<JsonValue>

Usage:

import { CONFIG_DATA } from '@isa/core/config';

{
  provide: CONFIG_DATA,
  useValue: myConfigObject
}

Config Service

Injectable service for accessing configuration data.

get<TOut>(path: string | string[], zSchema: z.ZodSchema<TOut>): TOut

Retrieves and validates configuration value at the specified path.

Parameters:

  • path: string | string[] - Path to configuration value (dot notation or array)
  • zSchema: z.ZodSchema<TOut> - Zod schema for validation and type inference

Returns: Validated value of type TOut

Throws: ZodError if validation fails

Example:

const apiUrl = this.#config.get('api.baseUrl', z.string().url());
// apiUrl is typed as string and validated as URL

const config = this.#config.get(
  'database',
  z.object({
    host: z.string(),
    port: z.number()
  })
);
// config is typed as { host: string; port: number; }

get(path: string | string[]): any (Deprecated)

Retrieves configuration value without validation.

Parameters:

  • path: string | string[] - Path to configuration value

Returns: Unvalidated value of type any

Deprecated: Use the version with Zod schema instead

Example:

// Not recommended
const value = this.#config.get('some.path');  // Type: any

Usage Examples

Basic Configuration Access

import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

@Component({
  selector: 'app-api-client',
  template: '...'
})
export class ApiClientComponent {
  #config = inject(Config);

  // Get string value
  apiBaseUrl = this.#config.get('api.baseUrl', z.string());

  // Get number value
  timeout = this.#config.get('api.timeout', z.number());

  // Get boolean value
  debugMode = this.#config.get('debug', z.boolean());

  ngOnInit() {
    console.log(`Connecting to ${this.apiBaseUrl}`);
    console.log(`Timeout: ${this.timeout}ms`);
    console.log(`Debug: ${this.debugMode}`);
  }
}

Complex Object Configuration

import { Component, inject, OnInit } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

// Define schema
const DatabaseConfigSchema = z.object({
  host: z.string().min(1),
  port: z.number().int().positive(),
  database: z.string().min(1),
  username: z.string(),
  password: z.string(),
  ssl: z.boolean().default(false),
  poolSize: z.number().int().positive().default(10)
});

type DatabaseConfig = z.infer<typeof DatabaseConfigSchema>;

@Component({
  selector: 'app-database',
  template: '...'
})
export class DatabaseComponent implements OnInit {
  #config = inject(Config);

  dbConfig!: DatabaseConfig;

  ngOnInit() {
    this.dbConfig = this.#config.get('database', DatabaseConfigSchema);

    console.log(`Connecting to ${this.dbConfig.host}:${this.dbConfig.port}`);
    console.log(`Database: ${this.dbConfig.database}`);
    console.log(`SSL: ${this.dbConfig.ssl}`);
  }
}

Array Configuration

import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

@Component({
  selector: 'app-cors-config',
  template: '...'
})
export class CorsConfigComponent {
  #config = inject(Config);

  // Array of strings
  allowedOrigins = this.#config.get(
    'cors.allowedOrigins',
    z.array(z.string().url())
  );

  // Array of numbers
  allowedPorts = this.#config.get(
    'security.allowedPorts',
    z.array(z.number().int().positive())
  );

  isOriginAllowed(origin: string): boolean {
    return this.allowedOrigins.includes(origin);
  }
}

Optional and Default Values

import { Component, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

@Component({
  selector: 'app-features',
  template: '...'
})
export class FeaturesComponent {
  #config = inject(Config);

  // Optional value (may be undefined)
  betaFeature = this.#config.get(
    'features.beta.enabled',
    z.boolean().optional()
  );

  // Value with default
  maxUploadSize = this.#config.get(
    'upload.maxSize',
    z.number().default(10485760)  // 10MB default
  );

  // Optional object
  experimentalConfig = this.#config.get(
    'experimental',
    z.object({
      enabled: z.boolean(),
      features: z.array(z.string())
    }).optional()
  );

  isBetaEnabled(): boolean {
    return this.betaFeature ?? false;
  }
}

Environment-Specific Configuration

// environment.ts
export const environment = {
  production: false,
  api: {
    baseUrl: 'http://localhost:3000',
    timeout: 30000
  },
  features: {
    analytics: false,
    debugPanel: true
  }
};

// environment.prod.ts
export const environment = {
  production: true,
  api: {
    baseUrl: 'https://api.production.com',
    timeout: 10000
  },
  features: {
    analytics: true,
    debugPanel: false
  }
};

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { CONFIG_DATA } from '@isa/core/config';
import { environment } from './environments/environment';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: CONFIG_DATA,
      useValue: environment
    }
  ]
};

// api.service.ts
import { Injectable, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

@Injectable({ providedIn: 'root' })
export class ApiService {
  #config = inject(Config);

  #apiConfig = this.#config.get(
    'api',
    z.object({
      baseUrl: z.string().url(),
      timeout: z.number()
    })
  );

  get baseUrl(): string {
    return this.#apiConfig.baseUrl;
  }

  get timeout(): number {
    return this.#apiConfig.timeout;
  }
}

Feature Flag Configuration

import { Injectable, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

const FeatureFlagsSchema = z.object({
  newDashboard: z.boolean().default(false),
  advancedSearch: z.boolean().default(false),
  exportToPdf: z.boolean().default(true),
  darkMode: z.boolean().default(false),
  experimentalFeatures: z.array(z.string()).default([])
});

type FeatureFlags = z.infer<typeof FeatureFlagsSchema>;

@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
  #config = inject(Config);

  #flags: FeatureFlags = this.#config.get('features', FeatureFlagsSchema);

  isEnabled(feature: keyof Omit<FeatureFlags, 'experimentalFeatures'>): boolean {
    return this.#flags[feature];
  }

  isExperimentalEnabled(feature: string): boolean {
    return this.#flags.experimentalFeatures.includes(feature);
  }
}

// Usage in component
@Component({
  selector: 'app-dashboard',
  template: `
    @if (featureFlags.isEnabled('newDashboard')) {
      <app-new-dashboard />
    } @else {
      <app-legacy-dashboard />
    }
  `
})
export class DashboardComponent {
  featureFlags = inject(FeatureFlagService);
}

Validation with Custom Error Handling

import { Component, inject, OnInit } from '@angular/core';
import { Config } from '@isa/core/config';
import { z, ZodError } from 'zod';

@Component({
  selector: 'app-config-validator',
  template: '...'
})
export class ConfigValidatorComponent implements OnInit {
  #config = inject(Config);

  ngOnInit() {
    try {
      const apiConfig = this.#config.get(
        'api',
        z.object({
          baseUrl: z.string().url('Invalid API URL'),
          timeout: z.number().positive('Timeout must be positive'),
          retries: z.number().int().min(0, 'Retries cannot be negative')
        })
      );

      console.log('Configuration valid:', apiConfig);
    } catch (error) {
      if (error instanceof ZodError) {
        console.error('Configuration validation failed:');
        error.errors.forEach(err => {
          console.error(`  - ${err.path.join('.')}: ${err.message}`);
        });

        // Fallback to defaults
        this.useFallbackConfig();
      }
    }
  }

  private useFallbackConfig() {
    console.warn('Using fallback configuration');
    // Use hardcoded defaults
  }
}

Configuration Patterns

Typed Configuration Service

Create a dedicated service with typed configuration access:

import { Injectable, inject } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';

// Define all schemas
const ApiConfigSchema = z.object({
  baseUrl: z.string().url(),
  timeout: z.number().positive(),
  retries: z.number().int().min(0)
});

const AuthConfigSchema = z.object({
  clientId: z.string(),
  redirectUri: z.string().url(),
  scopes: z.array(z.string())
});

const AppConfigSchema = z.object({
  name: z.string(),
  version: z.string(),
  buildNumber: z.string()
});

// Type exports
export type ApiConfig = z.infer<typeof ApiConfigSchema>;
export type AuthConfig = z.infer<typeof AuthConfigSchema>;
export type AppConfig = z.infer<typeof AppConfigSchema>;

@Injectable({ providedIn: 'root' })
export class AppConfigService {
  #config = inject(Config);

  // Lazy-loaded, cached configuration
  #apiConfig?: ApiConfig;
  #authConfig?: AuthConfig;
  #appConfig?: AppConfig;

  get api(): ApiConfig {
    if (!this.#apiConfig) {
      this.#apiConfig = this.#config.get('api', ApiConfigSchema);
    }
    return this.#apiConfig;
  }

  get auth(): AuthConfig {
    if (!this.#authConfig) {
      this.#authConfig = this.#config.get('auth', AuthConfigSchema);
    }
    return this.#authConfig;
  }

  get app(): AppConfig {
    if (!this.#appConfig) {
      this.#appConfig = this.#config.get('app', AppConfigSchema);
    }
    return this.#appConfig;
  }
}

// Usage
@Component({
  selector: 'app-root',
  template: '...'
})
export class AppComponent {
  appConfig = inject(AppConfigService);

  ngOnInit() {
    console.log(`API: ${this.appConfig.api.baseUrl}`);
    console.log(`Auth: ${this.appConfig.auth.clientId}`);
    console.log(`Version: ${this.appConfig.app.version}`);
  }
}

Dynamic Configuration Loading

Load configuration from external source at startup:

import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CONFIG_DATA } from '@isa/core/config';
import { inject } from '@angular/core';
import { lastValueFrom } from 'rxjs';

function loadConfiguration() {
  const http = inject(HttpClient);

  return async () => {
    try {
      const config = await lastValueFrom(
        http.get<JsonValue>('/assets/config.json')
      );
      return config;
    } catch (error) {
      console.error('Failed to load configuration', error);
      // Return fallback configuration
      return {
        api: { baseUrl: 'http://localhost:3000' }
      };
    }
  };
}

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: () => {
        const loadConfig = loadConfiguration();
        return () => loadConfig().then(config => {
          // Config will be available via CONFIG_DATA
        });
      },
      multi: true
    },
    {
      provide: CONFIG_DATA,
      useFactory: loadConfiguration()
    }
  ]
};

Multi-Environment Configuration

Structured configuration for multiple environments:

// config/base.config.ts
export const baseConfig = {
  app: {
    name: 'ISA Frontend',
    version: '1.0.0'
  },
  features: {
    reporting: true,
    analytics: true
  }
};

// config/development.config.ts
import { baseConfig } from './base.config';

export const developmentConfig = {
  ...baseConfig,
  api: {
    baseUrl: 'http://localhost:3000',
    timeout: 60000
  },
  features: {
    ...baseConfig.features,
    debugPanel: true,
    mockData: true
  }
};

// config/production.config.ts
import { baseConfig } from './base.config';

export const productionConfig = {
  ...baseConfig,
  api: {
    baseUrl: 'https://api.production.com',
    timeout: 30000
  },
  features: {
    ...baseConfig.features,
    debugPanel: false,
    mockData: false
  }
};

// app.config.ts
import { CONFIG_DATA } from '@isa/core/config';
import { developmentConfig } from './config/development.config';
import { productionConfig } from './config/production.config';
import { environment } from './environments/environment';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: CONFIG_DATA,
      useValue: environment.production ? productionConfig : developmentConfig
    }
  ]
};

Testing

The library uses Jest for testing.

Running Tests

# Run tests for this library
npx nx test core-config --skip-nx-cache

# Run tests with coverage
npx nx test core-config --code-coverage --skip-nx-cache

# Run tests in watch mode
npx nx test core-config --watch

Testing Components with Config

import { TestBed } from '@angular/core/testing';
import { Config, CONFIG_DATA } from '@isa/core/config';
import { MyComponent } from './my.component';

describe('MyComponent', () => {
  const mockConfig = {
    api: {
      baseUrl: 'http://test.example.com',
      timeout: 5000
    },
    features: {
      enabled: true
    }
  };

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: CONFIG_DATA,
          useValue: mockConfig
        }
      ]
    });
  });

  it('should access configuration', () => {
    const component = TestBed.createComponent(MyComponent).componentInstance;

    const config = component.getApiConfig();
    expect(config.baseUrl).toBe('http://test.example.com');
    expect(config.timeout).toBe(5000);
  });
});

Testing Config Service Directly

import { TestBed } from '@angular/core/testing';
import { Config, CONFIG_DATA } from '@isa/core/config';
import { z } from 'zod';

describe('Config Service', () => {
  let service: Config;

  const testConfig = {
    app: {
      name: 'Test App',
      version: '1.0.0'
    },
    features: {
      enabled: ['feature1', 'feature2']
    }
  };

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: CONFIG_DATA,
          useValue: testConfig
        }
      ]
    });
    service = TestBed.inject(Config);
  });

  it('should retrieve string value', () => {
    const name = service.get('app.name', z.string());
    expect(name).toBe('Test App');
  });

  it('should retrieve nested object', () => {
    const app = service.get(
      'app',
      z.object({
        name: z.string(),
        version: z.string()
      })
    );

    expect(app.name).toBe('Test App');
    expect(app.version).toBe('1.0.0');
  });

  it('should retrieve array', () => {
    const features = service.get('features.enabled', z.array(z.string()));
    expect(features).toEqual(['feature1', 'feature2']);
  });

  it('should throw on validation failure', () => {
    expect(() => {
      service.get('app.version', z.number());  // version is string, not number
    }).toThrow();
  });

  it('should handle missing path gracefully', () => {
    const missing = service.get('nonexistent.path', z.string().optional());
    expect(missing).toBeUndefined();
  });
});

Example Test Suite

import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { Config, CONFIG_DATA } from '@isa/core/config';
import { z } from 'zod';

describe('Config Service - Advanced', () => {
  let service: Config;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: CONFIG_DATA,
          useValue: {
            database: {
              host: 'localhost',
              port: 5432
            },
            timeout: 30000
          }
        }
      ]
    });
    service = TestBed.inject(Config);
  });

  it('should accept string path', () => {
    const host = service.get('database.host', z.string());
    expect(host).toBe('localhost');
  });

  it('should accept array path', () => {
    const host = service.get(['database', 'host'], z.string());
    expect(host).toBe('localhost');
  });

  it('should validate with schema', () => {
    const port = service.get('database.port', z.number().int().positive());
    expect(port).toBe(5432);
  });

  it('should apply default values', () => {
    const retries = service.get('retries', z.number().default(3));
    expect(retries).toBe(3);
  });
});

Architecture Notes

Current Architecture

Simple, focused architecture:

Components/Services
       ↓
  Config Service
       ↓
  CONFIG_DATA (Injection Token)
       ↓
  Application Configuration Object

Design Principles

1. Single Responsibility

The Config service has one job: retrieve configuration values by path.

Benefits:

  • Easy to understand
  • Easy to test
  • Easy to maintain

2. Dependency Injection

Uses Angular's DI system for configuration provisioning.

Benefits:

  • Easy to mock in tests
  • Easy to swap implementations
  • Runtime configuration injection

3. Type Safety through Validation

Zod schemas provide both runtime validation and compile-time types.

Benefits:

  • Catch configuration errors early
  • Type inference for better DX
  • Self-documenting configuration

Implementation Details

Path Resolution

The service uses Angular CDK's coerceArray for path handling:

import { coerceArray } from '@angular/cdk/coercion';

// Handles both string and array paths
const path = coerceArray('database.host');  // ['database', 'host']
const path2 = coerceArray(['database', 'host']);  // ['database', 'host']

Nested Property Access

Iterative navigation through object properties:

let result: JsonValue = this.#config;

for (const p of path) {
  if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
    result = result[p];
  } else {
    break;  // Can't navigate further
  }

  if (result === null || result === undefined) {
    break;  // Path doesn't exist
  }
}

Behavior:

  • Stops at first non-object or null/undefined
  • Returns last valid value found
  • Returns undefined if path doesn't exist

Performance Considerations

  1. No Caching - Service doesn't cache values (intentional for simplicity)
  2. Path Parsing - String paths split on every access (minimal overhead)
  3. Validation Cost - Zod validation runs on every access (consider caching in consuming services)

Optimization Pattern:

@Injectable({ providedIn: 'root' })
export class ApiService {
  #config = inject(Config);

  // Cache validated config (runs once)
  #apiConfig = this.#config.get('api', ApiConfigSchema);

  // Access cached value (no repeated validation)
  get baseUrl(): string {
    return this.#apiConfig.baseUrl;
  }
}

Known Limitations

1. Shallow JSON Structure Only

Current State:

  • Only supports JSON-compatible data
  • No support for functions, classes, symbols
  • Arrays can only contain primitives

Reason: Configuration should be serializable (JSON files, API responses)

Workaround: Transform configuration after loading if complex types needed

2. No Deep Array Access

Current State:

// This works
const features = config.get('features.enabled', z.array(z.string()));

// This doesn't work (can't index into arrays by path)
const firstFeature = config.get('features.enabled[0]', z.string());

Workaround: Retrieve array first, then index in application code

3. No Path Existence Check

Current State:

  • No dedicated method to check if path exists
  • Must attempt retrieval and handle undefined

Workaround: Use optional schemas

const value = config.get('maybe.exists', z.string().optional());
if (value !== undefined) {
  // Path exists
}

Future Enhancements

Potential improvements identified:

  1. Path Validation - Compile-time path checking with TypeScript
  2. Configuration Watching - React to configuration changes
  3. Configuration Merging - Merge multiple configuration sources
  4. Environment Validation - Validate entire config object at startup
  5. Path Autocomplete - IDE autocomplete for configuration paths
  6. Nested Array Access - Support array indexing in paths
  7. Configuration Schema - Define complete application configuration schema

Migration from Untyped Access

Gradual migration path:

// Old (untyped)
const url = this.#config.get('api.baseUrl');  // Type: any

// New (typed)
const url = this.#config.get('api.baseUrl', z.string());  // Type: string

Migration Strategy:

  1. Start with critical paths (API URLs, database config)
  2. Add schemas incrementally
  3. Use optional schemas for uncertain paths
  4. Eventually remove deprecated untyped access

Dependencies

Required Libraries

  • @angular/core - Angular framework
  • @angular/cdk/coercion - Array coercion utilities
  • zod - Schema validation

Path Alias

Import from: @isa/core/config

License

Internal ISA Frontend library - not for external distribution.