On this page

🔗The Manual Client Code Problem

Every frontend team faces the same problem: you have an OpenAPI spec that defines your API, but you need to write client code to call it. You write the TypeScript types. You write the fetch calls. You write the error handling. You write the request/response transformation.

Then the API changes. You update the OpenAPI spec. But you forget to update the client code. Your types are wrong. Your tests fail. You spend hours debugging why your API calls are broken.

The problem isn't just the maintenance burden—it's that manually written client code is error-prone. You miss required fields. You get the parameter types wrong. You forget to handle edge cases. And every framework (React, Vue, Angular, Svelte) has different patterns, so you're writing and maintaining the same logic four times.

MockForge v0.2.0 solves this by generating framework-specific, type-safe API clients directly from OpenAPI specs. This post covers the technical implementation of how it works.

🔗The Code Generation Pipeline

The generation pipeline is a multi-stage process:

graph TD
    A[OpenAPI Spec] -->|Parse| B[Unified Spec Parser]
    B -->|Validate| C[Schema Analysis]
    C -->|Extract| D[Operation Extraction]
    D -->|Transform| E[Framework Template]
    E -->|Generate| F[TypeScript Types]
    E -->|Generate| G[Framework Code]
    F -->|Bundle| H[Generated Client]
    G -->|Bundle| H

The pipeline starts with parsing and validating the OpenAPI spec, then extracts operations and schemas, transforms them into framework-specific patterns, and generates the final code.

🔗Unified Spec Parser: The Foundation

The unified spec parser is where it all starts. It's not just parsing JSON/YAML—it's validating the spec, resolving $ref references, and extracting structured data for code generation:

pub struct UnifiedSpecParser;

impl UnifiedSpecParser {
    pub fn parse(spec_path: &Path) -> Result<OpenApiSpec> {
        // Read and parse spec file
        let content = std::fs::read_to_string(spec_path)?;
        let spec: Value = if spec_path.extension() == Some("yaml".as_ref()) {
            serde_yaml::from_str(&content)?
        } else {
            serde_json::from_str(&content)?
        };

        // Validate OpenAPI version
        let version = spec.get("openapi")
            .or_else(|| spec.get("swagger"))
            .ok_or_else(|| Error::InvalidSpec("Missing OpenAPI version"))?;

        // Resolve $ref references
        let resolved = Self::resolve_references(&spec)?;

        // Extract structured data
        let paths = Self::extract_paths(&resolved)?;
        let schemas = Self::extract_schemas(&resolved)?;
        let operations = Self::extract_operations(&paths)?;

        Ok(OpenApiSpec {
            version: version.as_str().unwrap().to_string(),
            info: Self::extract_info(&resolved)?,
            paths,
            schemas,
            operations,
        })
    }

    fn resolve_references(spec: &Value) -> Result<Value> {
        // Deep traversal to resolve all $ref pointers
        // Handles both local references (#/components/schemas/User)
        // and external references (./schemas/user.yaml#/User)
        // ...
    }
}

The parser handles:

  • YAML and JSON formats
  • OpenAPI 3.0 and 3.1
  • Reference resolution (local and external)
  • Schema validation
  • Error reporting with file locations

This is critical: if the parser fails, you get clear error messages pointing to exactly what's wrong in your spec.

🔗Template System: Framework-Specific Code Generation

The template system is where framework-specific code is generated. Each framework (React, Vue, Angular, Svelte) has its own set of Handlebars templates:

pub struct ReactClientGenerator {
    templates: Handlebars<'static>,
}

impl ReactClientGenerator {
    fn register_templates(&mut self) -> Result<()> {
        // TypeScript types template
        self.templates.register_template_string("types", r#"
export interface {{name}} {
{{#each properties}}
  {{@key}}{{#unless required}}?{{/unless}}: {{> typescript_type this}};
{{/each}}
}"#)?;

        // React hook template
        self.templates.register_template_string("hook", r#"
export function use{{operation_id}}({{parameters}}) {
  return useQuery({
    queryKey: ['{{operation_id}}', {{query_params}}],
    queryFn: () => api.{{operation_id}}({{args}}),
  });
}"#)?;

        // API client template
        self.templates.register_template_string("client", r#"
export class ApiClient {
{{#each operations}}
  async {{operation_id}}({{params}}): Promise<{{response_type}}> {
    const response = await fetch(`${this.baseURL}{{path}}`, {
      method: '{{method}}',
      headers: { ...this.headers, ...headers },
      body: {{body}},
    });
    return response.json();
  }
{{/each}}
}"#)?;

        Ok(())
    }
}

The templates use Handlebars for flexible code generation. They support:

  • Conditionals ({{#if}}, {{#unless}})
  • Iteration ({{#each}})
  • Partials ({{> partial_name}})
  • Helpers for custom logic

This makes it easy to generate framework-specific patterns while sharing common logic.

🔗Type Generation: TypeScript from JSON Schema

The type generation is where OpenAPI schemas become TypeScript types. The generator handles complex schema features:

fn generate_typescript_type(schema: &Schema) -> String {
    match schema.type_.as_deref() {
        Some("string") => {
            if let Some(format) = &schema.format {
                match format.as_str() {
                    "date" => "string",
                    "date-time" => "string",
                    "email" => "string",
                    "uuid" => "string",
                    _ => "string",
                }
            } else if let Some(enum_) = &schema.enum_ {
                format!("{}", enum_.join(" | "))
            } else {
                "string"
            }
        }
        Some("number") | Some("integer") => {
            if schema.format.as_deref() == Some("int64") {
                "number" // JavaScript numbers are 64-bit floats
            } else {
                "number"
            }
        }
        Some("boolean") => "boolean",
        Some("array") => {
            if let Some(items) = &schema.items {
                format!("{}[]", generate_typescript_type(items))
            } else {
                "any[]"
            }
        }
        Some("object") => {
            if let Some(properties) = &schema.properties {
                let props: Vec<String> = properties
                    .iter()
                    .map(|(key, prop_schema)| {
                        let required = schema.required.as_ref()
                            .map(|r| r.contains(key))
                            .unwrap_or(false);
                        let type_ = generate_typescript_type(prop_schema);
                        format!("  {}:{};", key, if required { "" } else { "?" }, type_)
                    })
                    .collect();
                format!("{{\n{}\n}}", props.join("\n"))
            } else {
                "{ [key: string]: any }"
            }
        }
        _ => {
            // Handle $ref, oneOf, anyOf, allOf
            if let Some(ref_path) = &schema.ref_path {
                resolve_ref_type(ref_path)
            } else {
                "any"
            }
        }
    }
}

The type generator handles:

  • Primitive types with formats (date, email, uuid)
  • Arrays with item types
  • Objects with properties and required fields
  • Enums as union types
  • References ($ref) to component schemas
  • Complex schemas (oneOf, anyOf, allOf)

This ensures generated types are accurate and match your OpenAPI spec exactly.

🔗Framework-Specific Patterns

Each framework has different patterns. The generator adapts:

React:

// Generated React hook
export function useGetUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => api.getUser({ userId }),
  });
}

// Usage in component
function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useGetUser(userId);
  // ...
}

Vue:

// Generated Vue composable
export function useGetUser(userId: Ref<string>) {
  return useQuery({
    queryKey: computed(() => ['user', userId.value]),
    queryFn: () => api.getUser({ userId: userId.value }),
  });
}

Angular:

// Generated Angular service
@Injectable({ providedIn: 'root' })
export class ApiService {
  getUser(params: GetUserParams): Observable<User> {
    return this.http.get<User>(`/users/${params.userId}`);
  }
}

Svelte:

// Generated Svelte store
export function getUserStore(userId: string) {
  return writable<User | null>(null);
}

The generator understands each framework's conventions and generates idiomatic code.

🔗Request/Response Handling

Generated clients handle requests and responses correctly:

class ApiClient {
  async createUser(data: CreateUserRequest): Promise<User> {
    // Validate required fields
    if (!data.email) {
      throw new RequiredError('email');
    }

    // Build request
    const response = await fetch(`${this.baseURL}/users`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...this.getAuthHeaders(),
      },
      body: JSON.stringify(data),
    });

    // Handle errors
    if (!response.ok) {
      const errorBody = await response.json().catch(() => ({}));
      throw new ApiError(
        response.status,
        response.statusText,
        errorBody
      );
    }

    // Parse and return response
    return response.json();
  }
}

The generated code includes:

  • Required field validation
  • Request body serialization
  • Authentication headers
  • Error handling with structured errors
  • Response parsing

🔗Operation ID Sanitization

OpenAPI operation IDs aren't always valid JavaScript identifiers. The generator sanitizes them:

fn sanitize_identifier(operation_id: &str) -> String {
    // Replace invalid characters
    let sanitized = operation_id
        .replace(" ", "_")
        .replace("-", "_")
        .replace("/", "_")
        .replace("(", "")
        .replace(")", "");

    // Convert to camelCase
    let parts: Vec<&str> = sanitized.split('_').collect();
    let mut result = String::new();
    for (i, part) in parts.iter().enumerate() {
        if i == 0 {
            result.push_str(&part.to_lowercase());
        } else {
            result.push_str(&capitalize(part));
        }
    }

    result
}

This ensures generated method names are valid JavaScript identifiers while preserving the original operation ID's meaning.

🔗Code Organization: File Structure

Generated code is organized into a clear file structure:

generated/
├── types.ts          # TypeScript types
├── client.ts         # Base API client
├── hooks.ts          # React hooks (if React)
├── composables.ts    # Vue composables (if Vue)
├── service.ts        # Angular service (if Angular)
├── stores.ts         # Svelte stores (if Svelte)
├── errors.ts         # Error classes
└── index.ts          # Public API exports

This organization makes it easy to:

  • Import only what you need
  • Tree-shake unused code
  • Maintain clear separation of concerns
  • Extend with custom code

🔗Configuration and Customization

The generator supports extensive configuration:

generator:
  framework: react
  output:
    directory: ./src/api
    structure: flat  # or 'nested'
  naming:
    operations: camelCase  # or 'PascalCase', 'snake_case'
    types: PascalCase
  options:
    includeHooks: true
    includeTypes: true
    includeUtils: true
  filters:
    includeOperations:
      - getUser
      - createUser
    excludeOperations:
      - deleteUser

This gives you control over:

  • Output directory and file structure
  • Naming conventions
  • What gets generated
  • Which operations to include/exclude

🔗Error Handling in Generated Code

Generated clients include structured error handling:

export class ApiError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public body: any
  ) {
    super(`API Error: ${status} ${statusText}`);
  }

  isClientError(): boolean {
    return this.status >= 400 && this.status < 500;
  }

  isServerError(): boolean {
    return this.status >= 500 && this.status < 600;
  }
}

export class RequiredError extends Error {
  constructor(public field: string) {
    super(`Required parameter '${field}' was null or undefined`);
  }
}

This makes error handling consistent and type-safe across your application.

🔗The Bottom Line

MockForge v0.2.0's client generation transforms API integration from manual, error-prone work into automated, type-safe code generation. The generated clients include:

  • Complete TypeScript type definitions
  • Framework-specific patterns (hooks, composables, services)
  • Request/response validation
  • Structured error handling
  • Authentication support
  • Configurable code organization

This is production-ready client code, generated automatically from your OpenAPI spec. No manual type definitions, no handwritten API calls, no framework-specific boilerplate. Just working, type-safe code that stays in sync with your API spec.

For teams building frontend applications, this feature eliminates weeks of manual client code and months of maintenance. That's the value of automated code generation.