openapi: 3.0.3

# =============================================================================
# menu-item API — single source of truth (SSoT)
#
# This contract describes the live menu-item.com API. It is validated against
# the running code by tests/Feature/Api/OpenApiContractTest.php, so it cannot
# silently drift. Docs (Scalar/Redoc) and the MCP server are generated from
# this same file. Bump the version + add a changelog entry when the API changes.
#
# Written in 3.0.3 (not 3.1) for the widest tool compatibility: the PSR-7
# validator used by the contract test and the common doc renderers all support
# 3.0 fully, and the only practical difference here is `nullable: true` vs
# JSON-Schema union types.
# =============================================================================

info:
  title: menu-item API
  version: 1.0.0
  description: >-
    The search engine of dishes. Read-only access to restaurants, their
    extracted menu items and regional dish-price statistics for Belgian horeca.
    All endpoints require a Laravel Sanctum bearer token.
  contact:
    name: menu-item
    url: https://menu-item.com

servers:
  - url: https://menu-item.com/api
    description: Production
  - url: http://localhost:8000/api
    description: Local development

security:
  - bearerAuth: []

tags:
  - name: Restaurants
    description: Venues discovered in the catalogue.
  - name: Dishes
    description: Menu items across every restaurant; the search engine of dishes.
  - name: Account
    description: The authenticated token holder.

paths:
  /user:
    get:
      tags: [Account]
      operationId: getCurrentUser
      summary: Current token holder
      description: >-
        Returns the account the bearer token belongs to. Handy as a health check
        to confirm a token is valid. Note this endpoint is unversioned: it sits
        at `/api/user`, not under `/v1`.
      responses:
        '200':
          description: The authenticated user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  name: { type: string }
                  email: { type: string, format: email }
              example:
                id: 1
                name: Ada Lovelace
                email: ada@example.com
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/restaurants:
    get:
      tags: [Restaurants]
      operationId: listRestaurants
      summary: List restaurants
      description: >-
        Returns a paginated list of venues. Combine the filters below to narrow
        the catalogue; results default to alphabetical order. Each item carries
        the venue, its city and a `menu_items_count`.
      parameters:
        - name: search
          in: query
          description: Matches restaurant name or address (substring).
          schema: { type: string }
        - name: city
          in: query
          description: City slug (e.g. "brugge").
          schema: { type: string }
          example: brugge
        - name: type
          in: query
          description: Google Places primary_type (e.g. "restaurant", "bar", "cafe").
          schema: { type: string }
        - name: has_menu
          in: query
          description: Only venues with (true) or without (false) an extracted menu.
          schema: { type: boolean }
        - name: sort
          in: query
          description: Sort field; prefix with "-" for descending.
          schema:
            type: string
            enum: [name, price_level, created_at, -name, -price_level, -created_at]
            default: name
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: A paginated list of restaurants.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagination'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Restaurant' }
              example:
                data:
                  - id: 42
                    name: De Gastro
                    address: Langestraat 15, 8000 Brugge
                    latitude: 51.209
                    longitude: 3.2247
                    website: https://degastro.be
                    type: restaurant
                    price_level: 2
                    city: { name: Brugge, slug: brugge, country: BE }
                    menu_items_count: 48
                    menu_document:
                      id: 108
                      source_url: https://degastro.be/menukaart.pdf
                      document_date: '2025-11-02'
                      status: extracted
                      extracted_at: '2025-11-04T09:12:00Z'
                links:
                  first: https://menu-item.com/api/v1/restaurants?page=1
                  last: https://menu-item.com/api/v1/restaurants?page=6
                  prev: null
                  next: https://menu-item.com/api/v1/restaurants?page=2
                meta:
                  current_page: 1
                  from: 1
                  last_page: 6
                  per_page: 25
                  to: 25
                  total: 146
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/restaurants/{restaurant}:
    get:
      tags: [Restaurants]
      operationId: showRestaurant
      summary: Retrieve a restaurant
      description: >-
        Returns a single venue with its full latest menu (`menu`), the source
        document and, for rated venues, a value-for-money `score`.
      parameters:
        - name: restaurant
          in: path
          required: true
          description: Restaurant id.
          schema: { type: integer }
          example: 42
      responses:
        '200':
          description: The restaurant, including menu and (when rated) a value score.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Restaurant' }
              example:
                data:
                  id: 42
                  name: De Gastro
                  address: Langestraat 15, 8000 Brugge
                  latitude: 51.209
                  longitude: 3.2247
                  website: https://degastro.be
                  type: restaurant
                  price_level: 2
                  city: { name: Brugge, slug: brugge, country: BE }
                  menu_document:
                    id: 108
                    source_url: https://degastro.be/menukaart.pdf
                    document_date: '2025-11-02'
                    status: extracted
                    extracted_at: '2025-11-04T09:12:00Z'
                  score:
                    quality_score: 4.32
                    quality_basis: bayesiaans gewogen op 300 reviews
                    rating: 4.5
                    ratings_count: 300
                    avg_dish_price: 22.5
                    value_index: 78
                    value_label: sterke prijs-kwaliteit
                    price_position: gemiddeld
                    confidence: hoog
                    anomaly: null
                    sample_size: 48
                    disclaimer: >-
                      Indicatief en enkel gebaseerd op openbare Google-ratings en
                      onze eigen menuprijzen. Geen oordeel over de echtheid van reviews.
                  menu:
                    - id: 5001
                      section: Voorgerechten
                      name: Tomatensoep met balletjes
                      description: null
                      price: 8.5
                      currency: EUR
                      allergens: [selderij]
                      dietary_tags: []
                    - id: 5002
                      section: Hoofdgerechten
                      name: Vlaamse stoverij
                      description: met huisgemaakte friet
                      price: 22
                      currency: EUR
                      allergens: [gluten]
                      dietary_tags: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/menu-items:
    get:
      tags: [Dishes]
      operationId: searchMenuItems
      summary: Search dishes
      description: >-
        Searches dishes across every restaurant: the search engine of dishes.
        Each hit carries the dish and a compact reference to its restaurant and
        city.
      parameters:
        - name: dish
          in: query
          description: 'Dish name (substring). Alias: `name` is also accepted.'
          schema: { type: string }
          example: stoverij
        - name: name
          in: query
          description: Alias for `dish`.
          schema: { type: string }
        - name: section
          in: query
          description: Menu section (substring).
          schema: { type: string }
        - name: city
          in: query
          description: City slug (e.g. "brugge").
          schema: { type: string }
        - name: min_price
          in: query
          schema: { type: number }
        - name: max_price
          in: query
          schema: { type: number }
        - name: dietary
          in: query
          description: A single dietary tag the dish must carry (e.g. "vegetarian").
          schema: { type: string }
        - name: sort
          in: query
          schema:
            type: string
            enum: [name, price, section, -name, -price, -section]
            default: name
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: A paginated list of matching dishes.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagination'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/MenuItem' }
              example:
                data:
                  - id: 5002
                    section: Hoofdgerechten
                    name: Vlaamse stoverij
                    description: met huisgemaakte friet
                    price: 22
                    currency: EUR
                    allergens: [gluten]
                    dietary_tags: []
                    restaurant: { id: 42, name: De Gastro, city: brugge }
                  - id: 6110
                    section: Hoofdgerechten
                    name: Stoverij van 't huis
                    description: null
                    price: 24.5
                    currency: EUR
                    allergens: [gluten, selderij]
                    dietary_tags: []
                    restaurant: { id: 57, name: Brasserie Nord, city: brugge }
                links:
                  first: https://menu-item.com/api/v1/menu-items?page=1
                  last: https://menu-item.com/api/v1/menu-items?page=3
                  prev: null
                  next: https://menu-item.com/api/v1/menu-items?page=2
                meta:
                  current_page: 1
                  from: 1
                  last_page: 3
                  per_page: 25
                  to: 25
                  total: 61
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/dishes/stats:
    get:
      tags: [Dishes]
      operationId: dishStats
      summary: Dish price statistics
      description: >-
        Aggregates the price of a named dish across the region: sample size,
        average, min and max in EUR, plus up to ten of the cheapest examples.
        Scope it to a city with `city`.
      parameters:
        - name: name
          in: query
          required: true
          description: Dish name to aggregate on (2–100 chars).
          schema: { type: string, minLength: 2, maxLength: 100 }
          example: spareribs
        - name: city
          in: query
          description: Optional city slug to scope the aggregate.
          schema: { type: string }
          example: brugge
      responses:
        '200':
          description: Price aggregate with up to ten cheapest examples.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DishStats' }
              example:
                query: { dish: spareribs, city: brugge }
                sample_size: 11
                currency: EUR
                average_price: 24.8
                min_price: 19.5
                max_price: 31
                examples:
                  - { name: Spareribs, price: 19.5, restaurant: "'t Ribhuis", city: brugge }
                  - { name: Spareribs (baby back), price: 22, restaurant: Brasserie Nord, city: brugge }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A Laravel Sanctum personal access token.

  parameters:
    PerPage:
      name: per_page
      in: query
      description: Items per page (1–100).
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }

  responses:
    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string }
          example:
            message: Unauthenticated.
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            type: object
            properties:
              message: { type: string }
          example:
            message: No query results for model [Restaurant] 999.
    ValidationError:
      description: The request failed validation.
      content:
        application/json:
          schema:
            type: object
            required: [message, errors]
            properties:
              message: { type: string }
              errors:
                type: object
                additionalProperties:
                  type: array
                  items: { type: string }
          example:
            message: The name field is required.
            errors:
              name:
                - The name field is required.

  schemas:
    Pagination:
      type: object
      required: [data, links, meta]
      properties:
        data:
          type: array
          items: {}
        links:
          type: object
          description: first/last/prev/next page URLs.
        meta:
          type: object
          description: current_page, from, last_page, per_page, to, total, etc.

    City:
      type: object
      additionalProperties: false
      required: [name, slug, country]
      properties:
        name: { type: string }
        slug: { type: string }
        country: { type: string, description: ISO 3166-1 alpha-2 (e.g. "BE"). }

    MenuDocument:
      type: object
      nullable: true
      additionalProperties: false
      required: [id, source_url, document_date, status, extracted_at]
      properties:
        id: { type: integer }
        source_url: { type: string, nullable: true }
        document_date: { type: string, format: date, nullable: true }
        status: { type: string, description: e.g. "extracted". }
        extracted_at: { type: string, format: date-time, nullable: true }

    Score:
      type: object
      description: >-
        Value-for-money signal built only from Google's aggregate rating/review
        count and our own menu prices. Present only on the detail endpoint and
        only when the venue has a rating. Never a "fake review" verdict.
      additionalProperties: false
      required:
        - quality_score
        - quality_basis
        - rating
        - ratings_count
        - avg_dish_price
        - value_index
        - value_label
        - price_position
        - confidence
        - anomaly
        - sample_size
        - disclaimer
      properties:
        quality_score: { type: number, description: Bayesian weighted rating. }
        quality_basis: { type: string }
        rating: { type: number }
        ratings_count: { type: integer }
        avg_dish_price: { type: number, nullable: true }
        value_index: { type: integer, nullable: true }
        value_label: { type: string, nullable: true }
        price_position:
          type: string
          nullable: true
          description: goedkoop / gemiddeld / prijzig
        confidence:
          type: string
          description: beperkt / gemiddeld / hoog
        anomaly:
          type: string
          nullable: true
          description: Soft reliability flag, e.g. "ongewone_piek".
        sample_size: { type: integer }
        disclaimer: { type: string }

    Restaurant:
      type: object
      additionalProperties: false
      required:
        - id
        - name
        - address
        - latitude
        - longitude
        - website
        - type
        - price_level
      properties:
        id: { type: integer }
        name: { type: string }
        address: { type: string, nullable: true }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
        website: { type: string, nullable: true }
        type:
          type: string
          nullable: true
          description: Google Places primary_type.
        price_level:
          type: integer
          nullable: true
          description: 1–4 (Google price level).
        city:
          allOf: [{ $ref: '#/components/schemas/City' }]
          nullable: true
          description: Present when the city relation is loaded (both list and detail).
        menu_items_count:
          type: integer
          description: Present on the list endpoint (withCount).
        menu_document:
          allOf: [{ $ref: '#/components/schemas/MenuDocument' }]
          nullable: true
          description: Latest extracted menu document, when loaded.
        score:
          allOf: [{ $ref: '#/components/schemas/Score' }]
          nullable: true
          description: Present on the detail endpoint for rated venues only.
        menu:
          type: array
          description: Extracted dishes; present on the detail endpoint.
          items: { $ref: '#/components/schemas/MenuItem' }

    MenuItem:
      type: object
      additionalProperties: false
      required:
        - id
        - section
        - name
        - description
        - price
        - currency
        - allergens
        - dietary_tags
      properties:
        id: { type: integer }
        section: { type: string, nullable: true }
        name: { type: string }
        description: { type: string, nullable: true }
        price: { type: number, nullable: true }
        currency: { type: string, nullable: true, description: e.g. "EUR". }
        allergens:
          type: array
          nullable: true
          items: { type: string }
        dietary_tags:
          type: array
          nullable: true
          items: { type: string }
        restaurant:
          type: object
          nullable: true
          additionalProperties: false
          description: Present when the menu document relation is loaded (dish search).
          required: [id, name, city]
          properties:
            id: { type: integer }
            name: { type: string }
            city: { type: string, nullable: true }

    DishStats:
      type: object
      additionalProperties: false
      required: [query, sample_size, currency, average_price, min_price, max_price, examples]
      properties:
        query:
          type: object
          additionalProperties: false
          required: [dish, city]
          properties:
            dish: { type: string }
            city: { type: string, nullable: true }
        sample_size: { type: integer }
        currency: { type: string }
        average_price: { type: number, nullable: true }
        min_price: { type: number, nullable: true }
        max_price: { type: number, nullable: true }
        examples:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [name, price, restaurant, city]
            properties:
              name: { type: string }
              price: { type: number }
              restaurant: { type: string, nullable: true }
              city: { type: string, nullable: true }

# =============================================================================
# Guides — the conceptual prose of the reference (authentication, errors, ...).
# These are rendered on the docs page alongside the operations above, so the
# whole page derives from this one file. `x-` keeps them valid OpenAPI 3.0.3.
# =============================================================================
x-guides:
  - id: introduction
    title: Introduction
    heading: The search engine of dishes
    body: |-
      The menu-item API is a read-only REST interface over the
      menu-item.com catalogue: Belgian hospitality venues, the dishes
      extracted from their menus and regional price statistics. Every
      response is JSON and every request carries a bearer token.

      This page is generated from the same OpenAPI contract that validates
      the live API, so what you read here is exactly what the server
      returns. Point your integration at the base URL and go.
    note: |-
      Every endpoint is a `GET`. Nothing here can change data, so you are
      free to explore.
    sample:
      title: Your first request
      lang: bash
      code: |-
        curl "https://menu-item.com/api/v1/dishes/stats?name=spareribs" \
          -H "Authorization: Bearer YOUR_TOKEN"
  - id: authentication
    title: Authentication
    body: |-
      Authenticate every request with a Laravel Sanctum personal access
      token, sent as a bearer token in the `Authorization` header. You can
      create and revoke tokens from your account dashboard.

      Keep your token secret and use it only from server-side code. A
      missing or invalid token returns a `401` response.
    sample:
      title: Authorization header
      lang: bash
      code: |-
        curl "https://menu-item.com/api/v1/restaurants" \
          -H "Authorization: Bearer YOUR_TOKEN"
    sampleResponseRef: Unauthorized
  - id: base-url
    title: Base URL
    body: |-
      All endpoint paths are relative to the base URL. Versioned resources
      live under `/v1`; the `/user` health check is intentionally
      unversioned.

      Use the production host for live data. A local host is available when
      you run the API yourself.
    sample:
      title: Hosts
      lang: bash
      code: |-
        # Production
        https://menu-item.com/api

        # Local development
        http://localhost:8000/api
  - id: rate-limits
    title: Rate limits
    body: |-
      The API is rate limited per token so it stays fast for everyone. If
      you exceed the limit you receive a `429 Too Many Requests` response;
      wait a moment and retry.

      Be a good citizen: cache responses where you can and avoid tight
      polling loops.
    note: |-
      Price statistics and menus change slowly. Caching for a few minutes
      is usually plenty.
  - id: errors
    title: Errors
    body: |-
      The API uses conventional HTTP status codes. Any `4xx` or `5xx`
      response carries a JSON body with a `message`, and validation errors
      add a field-keyed `errors` object.

      - `200` OK: the request succeeded
      - `401` Unauthorized: the bearer token is missing or invalid
      - `404` Not Found: no resource matched the request
      - `422` Unprocessable Entity: a parameter failed validation
      - `429` Too Many Requests: you are being rate limited
      - `500` Server Error: something went wrong on our end
    sampleResponseRef: ValidationError
  - id: pagination
    title: Pagination
    body: |-
      List endpoints are paginated. Control the window with `per_page`
      (1 to 100, default 25) and `page`. Each response carries a `meta`
      block with the current position and totals, and a `links` block with
      ready-made URLs for the first, last, previous and next pages.

      To walk every page, follow `links.next` until it is `null`.
    sampleResponse:
      title: meta and links
      json: |-
        {
          "links": {
            "first": "https://menu-item.com/api/v1/restaurants?page=1",
            "last": "https://menu-item.com/api/v1/restaurants?page=6",
            "prev": null,
            "next": "https://menu-item.com/api/v1/restaurants?page=2"
          },
          "meta": {
            "current_page": 1,
            "from": 1,
            "last_page": 6,
            "per_page": 25,
            "to": 25,
            "total": 146
          }
        }
