openapi: 3.0.3
info:
  title: ashnet Logdaten-API
  description: >
    REST-API zur Bereitstellung der Test-Ride-Daten (Event Rental Manager)
    für die Robert Bosch GmbH.

    **Inkrementeller Abruf:** Der Record-Endpoint arbeitet Cursor-basiert.
    Jede Antwort enthält einen `nextCursor`, der die Leseposition im
    Append-only-Datenbestand markiert. Der Client speichert den zuletzt
    erhaltenen `nextCursor` und übergibt ihn beim nächsten Abruf als `cursor` –
    er erhält dann ausschließlich Datensätze, die seit dem letzten Abruf
    hinzugekommen sind. Ein Abruf ohne `cursor` beginnt beim ältesten Datensatz
    (Initial-Load). Cursor sind opak; ihre Struktur ist nicht Teil des API-Vertrags.
  version: "1.2.0"
  contact:
    name: ashnet GmbH
    email: info@ashnet.de
externalDocs:
  description: Datenbeschreibung v3 – Feldbeschreibung, Wertelisten, Beispieldatensätze (Excel; Markdown unter /docs/datenbeschreibung.md)
  url: /docs/datenbeschreibung.xlsx
tags:
  - name: Test Rides
    description: Abruf der Test-Ride-Daten (Bosch)
  - name: Betrieb
    description: Monitoring
servers:
  - url: /
    description: Dieser Host (für "Try it out" in der eingebetteten Dokumentation unter /docs)
  - url: https://analytics.rms.bosch.ashnet.cloud
    description: Produktion (Subdomain wird bei Go-Live final festgelegt)
security:
  - ApiKeyAuth: []
paths:
  /v1/datasets:
    get:
      tags: [Test Rides]
      summary: Verfügbare Datensätze für diesen API-Key
      description: Listet die Datensätze, die der API-Key abrufen darf, mit Primärschlüssel, Filtern, Anzahl und Endpunkt.
      operationId: listDatasets
      responses:
        '200':
          description: Datensatz-Register
          content:
            application/json:
              schema:
                type: object
                properties:
                  datasets:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string, example: event-rental }
                        title: { type: string }
                        description: { type: string }
                        idField: { type: string, example: rentKey }
                        tsField: { type: string, example: time }
                        filters: { type: array, items: { type: string } }
                        docs: { type: string, nullable: true }
                        records: { type: integer }
                        files: { type: integer }
                        endpoint: { type: string, example: /v1/event-rental/records }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/{dataset}/records:
    get:
      tags: [Test Rides]
      summary: Datensätze abrufen (inkrementell)
      description: >
        Liefert die Datensätze eines Datensatzes in Ingestion-Reihenfolge.
        Für den inkrementellen Abruf den `nextCursor` der letzten Antwort
        als `cursor` übergeben. Derzeit verfügbar: `event-rental` (Test Rides);
        weitere Datensätze werden über `/v1/datasets` bekannt gegeben und
        folgen demselben Abrufmuster.
      operationId: listRecords
      parameters:
        - name: dataset
          in: path
          required: true
          description: Schlüssel des Datensatzes (siehe /v1/datasets)
          schema: { type: string, enum: [event-rental], default: event-rental }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/From'
        - $ref: '#/components/parameters/To'
        - name: poolId
          in: query
          description: Auf ein Event (Pool) filtern
          schema: { type: string }
        - name: eventName
          in: query
          description: Auf einen Event-Namen filtern
          schema: { type: string }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Format'
      responses:
        '200':
          description: Seite mit Datensätzen und Folge-Cursor
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/TestRide' }
            application/x-ndjson:
              schema:
                type: string
                description: >
                  Ein Datensatz pro Zeile. Der Folge-Cursor wird im
                  Response-Header `X-Next-Cursor` geliefert.
          headers:
            X-RateLimit-Limit:
              description: Erlaubte Anfragen pro Minute und API-Key
              schema: { type: integer }
            X-RateLimit-Remaining:
              description: Verbleibende Anfragen im aktuellen Minutenfenster
              schema: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: API-Key ist für diesen Datensatz nicht freigeschaltet
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Unbekannter Datensatz
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Rate-Limit überschritten (Header `Retry-After` in Sekunden)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/health:
    get:
      tags: [Betrieb]
      summary: Health-Check
      security: []
      responses:
        '200':
          description: Dienst verfügbar
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  time: { type: string, format: date-time }
                  exportsOk:
                    type: boolean
                    description: true, wenn bei keinem Datensatz der letzte Export-Lauf fehlgeschlagen ist
                  oldestExportMinutes:
                    type: integer
                    nullable: true
                    description: Alter (Minuten) des ältesten letzten Export-Laufs über alle Datensätze
                  datasets:
                    type: object
                    description: Status je Datensatz
                    additionalProperties:
                      type: object
                      properties:
                        files: { type: integer }
                        records: { type: integer }
                        lastFile: { type: string, nullable: true }
                        lastExport:
                          type: object
                          nullable: true
                          properties:
                            ok: { type: boolean }
                            finishedAt: { type: string, format: date-time }
                            ageMinutes: { type: integer }
                            exported: { type: integer }
                            heldBack: { type: integer }
                            error: { type: string, nullable: true }
                        lastAccess:
                          type: object
                          nullable: true
                          properties:
                            at: { type: string, format: date-time }
                            count: { type: integer }
                            ageMinutes: { type: integer }
                        lastRequest:
                          type: object
                          nullable: true
                          properties:
                            at: { type: string, format: date-time }
                            status: { type: integer }
                  dataset:
                    type: object
                    description: Kompatibilität – entspricht datasets.event-rental
                    properties:
                      files: { type: integer }
                      records: { type: integer }
                      lastFile: { type: string, nullable: true }
                  lastExport:
                    type: object
                    nullable: true
                    description: Kurzform – letzter Export-Lauf des Standard-Datensatzes event-rental
                    properties:
                      ok: { type: boolean }
                      startedAt: { type: string, format: date-time }
                      finishedAt: { type: string, format: date-time }
                      exported: { type: integer }
                      error: { type: string, nullable: true }
                  lastAccess:
                    type: object
                    nullable: true
                    description: Letzter erfolgreicher Datenabruf über /v1/event-rental/records
                    properties:
                      at: { type: string, format: date-time }
                      count: { type: integer }
                      hasMore: { type: boolean }
                      ageMinutes: { type: integer }
                  lastRequest:
                    type: object
                    nullable: true
                    description: Letzte Anfrage an /v1/event-rental/records, egal mit welchem Ergebnis
                    properties:
                      at: { type: string, format: date-time }
                      status: { type: integer, example: 200 }
                  requests:
                    type: object
                    description: Zähler aller Anfragen an /v1/event-rental/records seit `since`
                    properties:
                      since: { type: string, format: date-time }
                      total: { type: integer }
                      ok: { type: integer }
                      error: { type: integer }
                      byStatus:
                        type: object
                        additionalProperties: { type: integer }
                        example: { "200": 1240, "401": 2, "429": 5 }
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
  parameters:
    Cursor:
      name: cursor
      in: query
      description: >
        Opaker Cursor aus `nextCursor` der letzten Antwort. Ohne Cursor beginnt
        der Abruf beim ältesten Datensatz.
      schema: { type: string }
    From:
      name: from
      in: query
      description: Nur Datensätze mit Ereigniszeitpunkt >= from (ISO8601)
      schema: { type: string, format: date-time }
    To:
      name: to
      in: query
      description: Nur Datensätze mit Ereigniszeitpunkt < to (ISO8601)
      schema: { type: string, format: date-time }
    Limit:
      name: limit
      in: query
      description: >
        Maximale Anzahl geprüfter Positionen pro Antwort. Mit Filtern
        (`from`, `to`, `poolId`, `eventName`) kann eine Seite daher weniger
        als `limit` Datensätze enthalten, obwohl `hasMore` true ist – dann
        einfach mit `nextCursor` weiterlesen.
      schema: { type: integer, minimum: 1, maximum: 10000, default: 1000 }
    Format:
      name: format
      in: query
      description: Antwortformat
      schema: { type: string, enum: [json, ndjson], default: json }
  responses:
    BadRequest:
      description: Ungültige Parameter (z.B. unbekannter Cursor)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Fehlender oder ungültiger API-Key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Page:
      type: object
      required: [items, count, nextCursor, hasMore]
      properties:
        count:
          type: integer
          description: Anzahl der Datensätze in dieser Antwort
        nextCursor:
          type: string
          description: >
            Cursor für den nächsten Abruf. Auch bei leerer Antwort gesetzt
            (dann unverändert zum übergebenen Cursor).
        hasMore:
          type: boolean
          description: true, wenn unmittelbar weitere Datensätze verfügbar sind
    Error:
      type: object
      properties:
        error: { type: boolean }
        url: { type: string }
        statusCode: { type: integer }
        statusMessage: { type: string }
        message: { type: string }
    TestRide:
      type: object
      description: >
        Abgeschlossener Test Ride gemäß Datenbeschreibung (Stand September 2026).
        Alle Zeitstempel ISO8601 in UTC. Fehlende Werte sind `null`; Bosch-Komponenten
        sind nur so vollständig, wie sie im Rental Manager gepflegt sind.
      required: [time, poolId, rentKey, returnedAt]
      properties:
        time: { type: string, format: date-time, description: Rückgabezeitpunkt (= Ende des Rides, identisch mit returnedAt) }
        poolId: { type: string, description: ID des Bike-Pools / Events im Rental Manager }
        eventName: { type: string, nullable: true, example: "Eurobike 2024 // Test Bikes" }
        eventStart: { type: string, format: date-time, nullable: true }
        eventEnd: { type: string, format: date-time, nullable: true }
        rentKey: { type: string, format: uuid, description: Eindeutiger Schlüssel des Mietvorgangs }
        bikeId: { type: string, nullable: true }
        bikeNumber: { type: string, nullable: true, example: "ext109" }
        bikeName: { type: string, nullable: true }
        category:
          type: string
          nullable: true
          description: Kategorie-Beschreibung aus dem Rental Manager (sonst Kurzname)
          example: Branded test bikes (rideable)
        elemCategory:
          type: string
          nullable: true
          description: E-Bike-Fahrzeugklasse (eMTB Fully, eGravel, eSUV, eUrban HUB, eUrban / eCity (Theo), eCargo, eTrekking, eCompact, eRoad)
        brand: { type: string, nullable: true }
        model: { type: string, nullable: true }
        modelYear: { type: string, nullable: true, example: "2025" }
        size: { type: string, nullable: true }
        framenumber: { type: string, nullable: true }
        locationName: { type: string, nullable: true }
        statusName: { type: string, nullable: true, example: Verfügbar }
        createdAt: { type: string, format: date-time, nullable: true, description: Buchungszeitpunkt (= Beginn des Rides) }
        plannedReturnAt: { type: string, format: date-time, nullable: true }
        returnedAt: { type: string, format: date-time }
        durationMin: { type: integer, nullable: true, description: Gebuchte Fahrtdauer in Minuten }
        ageBucket:
          type: string
          nullable: true
          enum: ["<18", "18-30", "31-50", "50-65", "65+", null]
          description: Altersgruppe zum Buchungszeitpunkt
        country:
          type: string
          nullable: true
          description: Herkunftsland als ISO-3166-1-alpha-2-Code (z.B. DE, CH, FR); nicht zuordenbare Freitexteingaben unverändert
          example: DE
        helmetRented: { type: boolean }
        smartphoneRented: { type: boolean }
        acceptedTerms: { type: boolean }
        newsletterSignup: { type: boolean }
        phoneProvided: { type: boolean, description: Telefonnummer angegeben (nur der Umstand) }
        emailProvided: { type: boolean, description: E-Mail-Adresse angegeben (nur der Umstand) }
        driveunit: { type: string, nullable: true, example: Performance Line CX (BDU384Y) }
        battery: { type: string, nullable: true, example: PowerTube 800 (BBP388Y) }
        battery_two: { type: string, nullable: true }
        display: { type: string, nullable: true, example: Kiox 300 (BHU3600) }
        remote: { type: string, nullable: true, example: "Mini Remote, 22,2 mm (BRC3300)" }
        charger: { type: string, nullable: true }
        bikestability: { type: string, nullable: true, example: ABS Pro Shimano (BAS3541) }
        ebikeprotect: { type: string, nullable: true, example: ConnectModule (BCM3100) }
        eShift: { type: string, nullable: true, enum: [ja, nein, null] }
        software_release_set: { type: string, nullable: true }
      additionalProperties: false
