Case Study 01: Specification-First API Development

Building a REST API by Writing an OpenAPI Spec First


Background

Priya Sharma is a backend developer at a mid-sized fintech startup. Her team has been tasked with building a new Transaction Categorization API -- a service that allows their mobile banking app to automatically categorize user transactions (groceries, dining, transportation, etc.) and provide spending summaries. The API will be consumed by three separate frontend teams: iOS, Android, and web.

In previous projects, Priya's team had used a code-first approach, building the API and then generating documentation afterward. This consistently led to problems: the frontend teams would discover undocumented edge cases, the response formats would shift between iterations, and integration testing was a constant source of friction. For this project, Priya decided to try a specification-first approach, writing a complete OpenAPI specification before any implementation code existed.

The Challenge

The Transaction Categorization API needed to support:

  • Ingesting raw transaction data from multiple bank feed formats
  • Categorizing transactions using configurable rule sets
  • Allowing users to override automatic categorizations
  • Providing spending summaries by category, time period, and merchant
  • Supporting bulk operations for historical transaction imports
  • Enforcing rate limits and authentication for all endpoints

The complexity was significant: the API would have 12 endpoints, multiple query parameter combinations, paginated list responses, and detailed error handling for malformed financial data. Getting the contract right before implementation would save the three frontend teams from constantly adapting to API changes.

Phase 1: Writing the OpenAPI Specification

Priya began by drafting the OpenAPI specification in YAML format. She started with the data models, knowing from experience that getting the schemas right was the foundation for everything else.

openapi: 3.1.0
info:
  title: Transaction Categorization API
  version: 1.0.0
  description: |
    Categorizes bank transactions and provides spending analytics.
    All endpoints require Bearer token authentication.
    Rate limit: 1000 requests per minute per API key.

servers:
  - url: https://api.fintrack.example.com/v1
    description: Production
  - url: https://staging-api.fintrack.example.com/v1
    description: Staging

components:
  schemas:
    Transaction:
      type: object
      required: [id, amount, currency, merchant_name, date, category]
      properties:
        id:
          type: string
          format: uuid
        amount:
          type: number
          format: decimal
          description: "Positive for credits, negative for debits"
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
          example: "USD"
        merchant_name:
          type: string
          maxLength: 200
        merchant_category_code:
          type: string
          pattern: "^\\d{4}$"
          description: "ISO 18245 MCC code"
        date:
          type: string
          format: date
        category:
          $ref: '#/components/schemas/Category'
        category_confidence:
          type: number
          minimum: 0.0
          maximum: 1.0
          description: "AI confidence score for auto-categorization"
        is_user_override:
          type: boolean
          default: false
        notes:
          type: string
          maxLength: 500

    Category:
      type: object
      required: [id, name, group]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          enum: [groceries, dining, transportation, housing,
                 utilities, entertainment, healthcare, shopping,
                 travel, education, income, transfer, other]
        group:
          type: string
          enum: [essentials, lifestyle, financial, other]
        icon:
          type: string
        color:
          type: string
          pattern: "^#[0-9A-Fa-f]{6}$"

    SpendingSummary:
      type: object
      required: [period_start, period_end, total_spent, by_category]
      properties:
        period_start:
          type: string
          format: date
        period_end:
          type: string
          format: date
        total_spent:
          type: number
          format: decimal
        total_income:
          type: number
          format: decimal
        by_category:
          type: array
          items:
            type: object
            properties:
              category:
                type: string
              amount:
                type: number
                format: decimal
              percentage:
                type: number
                minimum: 0
                maximum: 100
              transaction_count:
                type: integer

    ErrorResponse:
      type: object
      required: [error_code, message]
      properties:
        error_code:
          type: string
          description: "Machine-readable error code"
        message:
          type: string
          description: "Human-readable error message"
        details:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              issue:
                type: string

She then defined the endpoints, paying particular attention to error responses:

paths:
  /transactions:
    post:
      operationId: createTransaction
      summary: Create a single transaction
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTransactionRequest'
      responses:
        '201':
          description: Transaction created and categorized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transaction'
        '400':
          description: Invalid transaction data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_amount:
                  value:
                    error_code: "INVALID_AMOUNT"
                    message: "Amount must be a valid decimal number"
                invalid_currency:
                  value:
                    error_code: "INVALID_CURRENCY"
                    message: "Currency must be a valid ISO 4217 code"
        '401':
          description: Authentication required
        '429':
          description: Rate limit exceeded
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until rate limit resets

    get:
      operationId: listTransactions
      summary: List transactions with filtering
      parameters:
        - name: start_date
          in: query
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          schema:
            type: string
            format: date
        - name: category
          in: query
          schema:
            type: string
        - name: min_amount
          in: query
          schema:
            type: number
        - name: max_amount
          in: query
          schema:
            type: number
        - name: merchant
          in: query
          schema:
            type: string
          description: "Partial match, case-insensitive"
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: sort
          in: query
          schema:
            type: string
            enum: [date_asc, date_desc, amount_asc, amount_desc]
            default: date_desc
      responses:
        '200':
          description: Paginated transaction list
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items:
                      $ref: '#/components/schemas/Transaction'
                  pagination:
                    $ref: '#/components/schemas/PaginationInfo'

The full specification ran to 450 lines, covering all 12 endpoints with complete request/response schemas.

Phase 2: Review and Validation

Before writing any code, Priya shared the OpenAPI specification with the three frontend teams. They loaded it into Swagger UI and could immediately see every endpoint, try sample requests, and review the response formats. The iOS team identified that they needed an additional field (original_currency and exchange_rate) for international transactions. The web team requested that the spending summary endpoint support grouping by week, not just by month. These changes were made to the YAML file in minutes -- far easier than refactoring implemented code.

Priya also used an OpenAPI linter to validate the specification for consistency and completeness, catching several issues: a missing required field in one schema, an endpoint that referenced a non-existent component, and inconsistent pagination parameter names.

Phase 3: AI-Powered Implementation

With the validated specification in hand, Priya used it as the primary prompt for her AI coding assistant. Her prompt strategy was methodical:

Prompt 1: Data models and database layer

Using the component schemas from this OpenAPI specification,
generate SQLAlchemy 2.x models with async support for PostgreSQL.
Include all constraints, relationships, and indexes suggested by the
schema. [attached: full OpenAPI spec]

The AI generated models that exactly matched her schema definitions -- UUID primary keys, decimal amounts with appropriate precision, enum constraints, and proper indexes for the query patterns implied by the list endpoint's filter parameters.

Prompt 2: API endpoints, one group at a time

Using this OpenAPI specification, implement the /transactions
endpoints (POST, GET, GET/{id}, PUT/{id}, DELETE/{id}) using FastAPI.
Use the SQLAlchemy models from the previous step. Implement all
defined error responses with the exact error codes shown in the spec.
[attached: OpenAPI spec + generated models]

Prompt 3: Spending analytics endpoints

Implement the /analytics/spending endpoint from this spec. It must
support grouping by day, week, and month. Use CTEs for the SQL queries
to maintain readability. [attached: relevant spec section + models]

Each prompt produced code that was remarkably close to the desired output. The AI matched the exact response formats, implemented all the error codes defined in the spec, and used the correct HTTP status codes because they were all explicitly defined.

Phase 4: Contract Testing

Because the specification existed before the code, Priya used Schemathesis (an API testing tool that generates test cases from OpenAPI specs) to automatically test every endpoint against the contract. The tool found three issues:

  1. The bulk import endpoint accepted arrays larger than the specified maximum of 500 items
  2. The merchant search did not properly handle special characters in query parameters
  3. One error response returned error instead of the specified error_code field name

All three were quick fixes. Without the specification as a contract, these inconsistencies might not have been caught until the frontend teams encountered them in integration.

Results and Lessons Learned

The specification-first approach delivered measurable benefits:

Time savings: The total project took 3 weeks. Priya estimated that a code-first approach would have taken 4-5 weeks, with the extra time spent on integration issues and API changes.

Fewer integration bugs: The frontend teams reported zero API contract surprises during integration. Every response matched what they had seen in the Swagger documentation.

Better AI output quality: Priya estimated that 80% of the AI-generated code was usable with minimal changes, compared to roughly 50% in previous projects using conversational prompts.

Living documentation: The OpenAPI specification served as always-current documentation, automatically rendered as interactive API docs for the team.

Key lessons Priya documented for her team:

  1. Invest time in schema design first. The component schemas took the longest to write but paid off the most. Getting the data models right meant every endpoint built on a solid foundation.

  2. Include error response examples in the spec. Specific error codes and messages in the specification caused the AI to generate specific error handling rather than generic try/except blocks.

  3. Use the spec for validation, not just documentation. Tools like Schemathesis and openapi-spec-validator turned the specification into an automated quality gate.

  4. Feed the spec to AI in logical sections, not all at once. The full 450-line specification was too long for a single prompt. Breaking it into model generation, then endpoint groups, then analytics produced better results.

  5. Keep the spec as the source of truth. When changes were needed, Priya updated the specification first, then regenerated the affected code. This maintained the specification's role as the single source of truth.

Conclusion

Priya's experience demonstrates the core thesis of specification-driven prompting: the time invested in writing a clear specification is repaid many times over in reduced iteration, fewer bugs, and more predictable AI output. For APIs consumed by multiple teams, the specification-first approach is not just beneficial -- it is essential.

The OpenAPI specification served three roles simultaneously: a communication tool for the frontend teams, a precision prompt for the AI coding assistant, and a contract testing foundation for quality assurance. This triple duty makes API specifications one of the highest-value artifacts in specification-driven development.