openapi: 3.0.3
info:
  title: BonoVía API
  version: 1.0.0
  description: 'API pública de BonoVía: búsqueda de estaciones, destinos alcanzables y salidas de transporte.
    Requiere una API key (`X-API-Key`) en cada petición — consigue una gratis en `POST /v1/keys`.
    Datos derivados de feeds GTFS; consulta `/v1/health` para conocer la frescura del paquete de datos activo.'
servers:
- url: https://api.bonovia.app
  description: API pública
security:
- ApiKeyAuth: []
paths:
  /v1/keys:
    post:
      summary: Crear una API key (self-service, sin autenticación)
      operationId: createApiKey
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  description: Opcional — para poder recuperar tu key o contactarte si hay abuso
                expiresInDays:
                  type: integer
                  enum: [1, 7, 30, 90]
                  nullable: true
                  description: Cuándo expira la key. Omite el campo o pásalo como `null` para que no expire nunca (zona de peligro — revócala tú mismo si ya no la usas).
      responses:
        '201':
          description: Key creada — el valor de `key` sólo se muestra una vez
          content:
            application/json:
              schema:
                type: object
                properties:
                  key:
                    type: string
                    example: bv_live_a1b2c3d4e5f6...
                  prefix:
                    type: string
                    description: Prefijo visible de la key, para identificarla sin exponerla
                  created_at:
                    type: string
                    format: date-time
                  expires_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: null si se creó con expiresInDays omitido/null (nunca expira)
                  note:
                    type: string
        '400':
          description: Email con formato inválido, o expiresInDays fuera de [1, 7, 30, 90]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Demasiadas keys creadas desde esta IP (límite por hora)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/keys/revoke:
    post:
      summary: Revocar tu propia API key (self-service)
      operationId: revokeApiKey
      description: La key enviada en X-API-Key es tanto la autenticación como la que se revoca — no hace falta ningún otro identificador.
      responses:
        '200':
          description: Key revocada — deja de funcionar en todas las peticiones futuras
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked:
                    type: boolean
        '401':
          description: Falta X-API-Key o no es válida
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/keys/usage:
    get:
      summary: Estadísticas de uso de tu propia API key
      operationId: getUsage
      responses:
        '200':
          description: Uso de los últimos 30 días para la key autenticada
          content:
            application/json:
              schema:
                type: object
                properties:
                  created_at:
                    type: string
                    format: date-time
                    nullable: true
                  last_used_at:
                    type: string
                    format: date-time
                    nullable: true
                  expires_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: null significa que la key no expira
                  total_requests_last_30d:
                    type: integer
                  by_endpoint:
                    type: array
                    items:
                      type: object
                      properties:
                        endpoint:
                          type: string
                        count:
                          type: integer
                  daily:
                    type: array
                    items:
                      type: object
                      properties:
                        day:
                          type: string
                          format: date
                        count:
                          type: integer
        '401':
          description: Falta X-API-Key o no es válida
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/health:
    get:
      summary: Frescura y salud de los datos GTFS
      operationId: getHealth
      responses:
        '200':
          description: Datos frescos (<= 10 días) y todas las comprobaciones correctas
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthReport'
        '503':
          description: Datos obsoletos o alguna comprobación falló
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthReport'
        '500':
          description: Error interno al conectar con la base de datos
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  ms:
                    type: integer
        '401':
          description: Falta X-API-Key o no es válida
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/stations:
    get:
      summary: Buscar estaciones por nombre
      operationId: searchStations
      parameters:
      - name: q
        in: query
        required: true
        description: Texto de búsqueda (mínimo 2 caracteres, sin distinguir acentos ni mayúsculas)
        schema:
          type: string
          minLength: 2
      responses:
        '200':
          description: Estaciones y agrupaciones de ciudad que coinciden
          content:
            application/json:
              schema:
                type: object
                properties:
                  stations:
                    type: array
                    items:
                      $ref: '#/components/schemas/Station'
                  count:
                    type: integer
        '400':
          description: Falta `q` o tiene menos de 2 caracteres
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Falta X-API-Key o no es válida
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/explore:
    get:
      summary: Destinos alcanzables desde una estación de origen
      operationId: exploreDestinations
      parameters:
      - name: from
        in: query
        required: true
        description: stop_id de origen
        schema:
          type: string
      - name: date
        in: query
        required: false
        description: Fecha del viaje, formato DD-MM-YYYY (también acepta ISO YYYY-MM-DD; por defecto hoy)
        schema:
          type: string
          pattern: '^\d{2}-\d{2}-\d{4}$'
          example: '23-09-2026'
      - name: maxTime
        in: query
        required: false
        description: Duración máxima del trayecto, en minutos
        schema:
          type: integer
          default: 360
      - name: page
        in: query
        required: false
        description: Número de página (1-indexado). Un origen con muchos destinos (p. ej. Madrid) no cabe en una sola respuesta.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: perPage
        in: query
        required: false
        description: Destinos por página
        schema:
          type: integer
          default: 200
          maximum: 500
      responses:
        '200':
          description: Página de destinos alcanzables (sólo trayectos directos)
          content:
            application/json:
              schema:
                type: object
                properties:
                  origin:
                    type: string
                  origin_lat:
                    type: number
                  origin_lon:
                    type: number
                  date:
                    type: string
                    format: date
                  max_time_minutes:
                    type: integer
                  verano_joven_active:
                    type: boolean
                  destinations:
                    type: array
                    items:
                      $ref: '#/components/schemas/Destination'
                  count:
                    type: integer
                    description: Número de destinos en esta página
                  page:
                    type: integer
                  per_page:
                    type: integer
                  total:
                    type: integer
                    description: Número total de destinos a través de todas las páginas
                  total_pages:
                    type: integer
                  precomputed:
                    type: boolean
        '400':
          description: Falta `from`, la fecha es inválida, o es anterior a hoy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Falta X-API-Key o no es válida
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/departures:
    get:
      summary: Próximas salidas desde una estación
      operationId: getDepartures
      parameters:
      - name: from
        in: query
        required: true
        description: stop_id de origen
        schema:
          type: string
      - name: date
        in: query
        required: false
        description: Fecha, formato YYYY-MM-DD (por defecto hoy)
        schema:
          type: string
          format: date
      - name: limit
        in: query
        required: false
        description: Número máximo de salidas devueltas
        schema:
          type: integer
          default: 50
      responses:
        '200':
          description: Salidas ordenadas por hora
          content:
            application/json:
              schema:
                type: object
                properties:
                  station_id:
                    type: string
                  date:
                    type: string
                    format: date
                  departures:
                    type: array
                    items:
                      $ref: '#/components/schemas/Departure'
                  count:
                    type: integer
        '400':
          description: Falta `from` o la fecha es inválida
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Falta X-API-Key o no es válida
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
    HealthReport:
      type: object
      properties:
        ok:
          type: boolean
        package_date:
          type: string
          format: date
          nullable: true
        package_age_days:
          type: integer
          nullable: true
        checks:
          type: object
          additionalProperties:
            type: object
            properties:
              ok:
                type: boolean
              ms:
                type: integer
        ms:
          type: integer
    Operator:
      type: object
      properties:
        operator:
          type: string
        mode:
          type: string
          enum:
          - train
          - bus
        services:
          type: array
          items:
            type: string
    Station:
      type: object
      properties:
        stop_id:
          type: string
        stop_name:
          type: string
        stop_lat:
          type: number
        stop_lon:
          type: number
        operators:
          type: array
          items:
            $ref: '#/components/schemas/Operator'
        is_cluster:
          type: boolean
          description: true si agrupa varias estaciones de una misma ciudad
        cluster_stop_count:
          type: integer
    Destination:
      type: object
      properties:
        stop_id:
          type: string
        stop_name:
          type: string
        stop_lat:
          type: number
        stop_lon:
          type: number
        duration_minutes:
          type: integer
        departure_time:
          type: string
        arrival_time:
          type: string
        route_type:
          type: string
        trip_id:
          type: string
        feed:
          type: string
        operator:
          type: string
        mode:
          type: string
          enum:
          - train
          - bus
    Departure:
      type: object
      properties:
        trip_id:
          type: string
        departure_time:
          type: string
        arrival_time:
          type: string
        stop_sequence:
          type: integer
        route_short_name:
          type: string
        route_long_name:
          type: string
        trip_headsign:
          type: string
        feed:
          type: string
        operator:
          type: string
        mode:
          type: string
          enum:
          - train
          - bus
