openapi: 3.0.3
info:
  title: Gear API
  version: 1.0.0
  license:
    name: MIT
    url: https://opensource.org/license/mit/
  description: |
    Authenticated API for managing gear, categories, images, and queued product
    imports. All API endpoints are rooted at `/api/v1`.
servers:
  - url: /api/v1
tags:
  - name: Health
    description: Service health.
  - name: Authentication
    description: Registration, login, and bearer token management.
  - name: Categories
    description: System and user-owned categories.
  - name: Gear items
    description: Owner-scoped inventory items and images.
  - name: Product imports
    description: Asynchronous imports from public product URLs.
paths:
  /health:
    get:
      tags: [Health]
      operationId: health
      summary: Health check
      security: []
      responses:
        '200':
          description: Service is responding.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
        '404':
          $ref: '#/components/responses/NotFound'
  /auth/register:
    post:
      tags: [Authentication]
      operationId: register
      summary: Register a user
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: User and bearer token created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /auth/login:
    post:
      tags: [Authentication]
      operationId: login
      summary: Log in
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: User and bearer token returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        '401':
          $ref: '#/components/responses/InvalidCredentials'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /auth/me:
    get:
      tags: [Authentication]
      operationId: currentUser
      summary: Get the authenticated user
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Authenticated user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
  /auth/logout:
    post:
      tags: [Authentication]
      operationId: logout
      summary: Revoke the current bearer token
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Token revoked.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
  /categories:
    get:
      tags: [Categories]
      operationId: listCategories
      summary: List system and owned categories
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Categories visible to the authenticated user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryCollectionResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
    post:
      tags: [Categories]
      operationId: createCategory
      summary: Create a category
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryRequest'
      responses:
        '201':
          description: Category created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '422':
          $ref: '#/components/responses/ValidationError'
  /categories/{category}:
    parameters:
      - $ref: '#/components/parameters/CategoryId'
    get:
      tags: [Categories]
      operationId: getCategory
      summary: Get a category
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Category.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [Categories]
      operationId: updateCategory
      summary: Rename an owned category
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoryRequest'
      responses:
        '200':
          description: Category updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [Categories]
      operationId: deleteCategory
      summary: Delete an owned category
      security: [{ bearerAuth: [] }]
      responses:
        '204':
          description: Category deleted.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Category is in use.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /gear-items:
    get:
      tags: [Gear items]
      operationId: listGearItems
      summary: List owned gear items
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/PerPage' }
        - { $ref: '#/components/parameters/Search' }
        - { $ref: '#/components/parameters/CategoryFilter' }
        - { $ref: '#/components/parameters/InPossession' }
        - { $ref: '#/components/parameters/Ordered' }
        - { $ref: '#/components/parameters/Sort' }
      responses:
        '200':
          description: Paginated gear items with an aggregate summary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GearItemCollectionResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '422':
          $ref: '#/components/responses/ValidationError'
    post:
      tags: [Gear items]
      operationId: createGearItem
      summary: Create a gear item
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GearItemRequest'
      responses:
        '201':
          description: Gear item created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GearItemResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '422':
          $ref: '#/components/responses/ValidationError'
  /gear-items/{gear_item}:
    parameters:
      - $ref: '#/components/parameters/GearItemRouteId'
    get:
      tags: [Gear items]
      operationId: getGearItem
      summary: Get a gear item
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Gear item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GearItemResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [Gear items]
      operationId: updateGearItem
      summary: Update a gear item
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GearItemUpdateRequest'
      responses:
        '200':
          description: Gear item updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GearItemResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [Gear items]
      operationId: deleteGearItem
      summary: Delete a gear item
      security: [{ bearerAuth: [] }]
      responses:
        '204':
          description: Gear item deleted.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
  /gear-items/{gearItem}/image:
    parameters:
      - $ref: '#/components/parameters/GearItemId'
    post:
      tags: [Gear items]
      operationId: uploadGearImage
      summary: Upload or replace a gear image
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [image]
              properties:
                image:
                  type: string
                  format: binary
                  description: JPEG, PNG, or WebP up to 5 MB and 4096×4096 pixels.
      responses:
        '200':
          description: Gear item with the new image URL.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GearItemResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [Gear items]
      operationId: deleteGearImage
      summary: Delete a gear image
      security: [{ bearerAuth: [] }]
      responses:
        '204':
          description: Image deleted.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
  /product-imports:
    get:
      tags: [Product imports]
      operationId: listProductImports
      summary: List owned product imports
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Paginated imports.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductImportCollectionResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
    post:
      tags: [Product imports]
      operationId: createProductImport
      summary: Queue a product import from a public URL
      description: The import is asynchronous; poll the returned import resource for completion.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductImportRequest'
      responses:
        '202':
          description: Import accepted and queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductImportResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /product-imports/{product_import}:
    parameters:
      - name: product_import
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    get:
      tags: [Product imports]
      operationId: getProductImport
      summary: Get an import status
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Import status and result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductImportResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Product imports]
      operationId: deleteProductImport
      summary: Delete an import record
      security: [{ bearerAuth: [] }]
      responses:
        '204':
          description: Import deleted.
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Sanctum
  parameters:
    CategoryId:
      name: category
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    GearItemId:
      name: gearItem
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    GearItemRouteId:
      name: gear_item
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    PerPage:
      name: per_page
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Search:
      name: q
      in: query
      schema: { type: string, maxLength: 255 }
    CategoryFilter:
      name: category_id
      in: query
      schema: { type: integer, minimum: 1 }
    InPossession:
      name: in_possession
      in: query
      schema: { type: boolean }
    Ordered:
      name: ordered
      in: query
      schema: { type: boolean }
    Sort:
      name: sort
      in: query
      description: Prefix with `-` for descending order.
      schema:
        type: string
        default: -created_at
        enum: [name, -name, quantity, -quantity, weight_grams, -weight_grams, price_minor, -price_minor, created_at, -created_at, updated_at, -updated_at]
  responses:
    Unauthenticated:
      description: Authentication is required.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    Forbidden:
      description: The authenticated user is not authorized.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    ValidationError:
      description: Request validation failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationErrorResponse' }
    RateLimited:
      description: Too many requests.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    InvalidCredentials:
      description: Credentials are invalid.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
  schemas:
    User:
      type: object
      required: [id, name, email, preferred_currency, preferred_weight_unit]
      properties:
        id: { type: integer, example: 1 }
        name: { type: string, example: Alex Hiker }
        email: { type: string, format: email, example: alex@example.com }
        preferred_currency: { type: string, minLength: 3, maxLength: 3, nullable: true }
        preferred_weight_unit: { type: string, enum: [g, kg, oz, lb], nullable: true }
        email_verified_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time, nullable: true }
    UserResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/User' }
    AuthResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [user, token]
          properties:
            user: { $ref: '#/components/schemas/User' }
            token: { type: string, description: Sanctum personal access token }
    RegisterRequest:
      type: object
      required: [name, email, password, password_confirmation]
      properties:
        name: { type: string, maxLength: 255 }
        email: { type: string, format: email, maxLength: 255 }
        password: { type: string, format: password, minLength: 8 }
        password_confirmation: { type: string, format: password }
    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password }
    Category:
      type: object
      required: [id, name, is_system]
      properties:
        id: { type: integer }
        name: { type: string }
        is_system: { type: boolean }
        created_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }
    CategoryRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 100 }
    CategoryResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Category' }
    CategoryCollectionResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Category' }
    GearItem:
      type: object
      required: [id, name, category, quantity, weight_grams, total_weight_grams, price_minor, total_value_minor, currency_code, product_url, image_url, image_source_url, in_possession, ordered, status, notes]
      properties:
        id: { type: integer }
        name: { type: string }
        category: { $ref: '#/components/schemas/Category' }
        quantity: { type: integer, minimum: 1 }
        weight_grams: { type: integer, minimum: 0 }
        total_weight_grams: { type: integer, minimum: 0 }
        price_minor: { type: integer, minimum: 0, nullable: true }
        total_value_minor: { type: integer, minimum: 0, nullable: true }
        currency_code: { type: string, minLength: 3, maxLength: 3, nullable: true, example: USD }
        product_url: { type: string, format: uri, nullable: true }
        image_url: { type: string, format: uri, nullable: true }
        image_source_url: { type: string, format: uri, nullable: true }
        in_possession: { type: boolean }
        ordered: { type: boolean }
        status: { type: string, enum: [owned, ordered, wishlist] }
        notes: { type: string, nullable: true }
        imported_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }
    GearItemRequest:
      type: object
      required: [category_id, name, quantity, weight_grams, in_possession, ordered]
      properties:
        category_id: { type: integer, minimum: 1 }
        name: { type: string, maxLength: 255 }
        quantity: { type: integer, minimum: 1, maximum: 999 }
        weight_grams: { type: integer, minimum: 0, maximum: 100000000 }
        price_minor: { type: integer, minimum: 0, nullable: true }
        currency_code: { type: string, pattern: '^[A-Z]{3}$', nullable: true }
        product_url: { type: string, format: uri, nullable: true }
        in_possession: { type: boolean }
        ordered: { type: boolean }
        notes: { type: string, maxLength: 10000, nullable: true }
        image_source_url: { type: string, format: uri, nullable: true }
    GearItemUpdateRequest:
      description: All fields are optional for updates.
      type: object
      properties:
        category_id: { type: integer, minimum: 1 }
        name: { type: string, maxLength: 255 }
        quantity: { type: integer, minimum: 1, maximum: 999 }
        weight_grams: { type: integer, minimum: 0, maximum: 100000000 }
        price_minor: { type: integer, minimum: 0, nullable: true }
        currency_code: { type: string, pattern: '^[A-Z]{3}$', nullable: true }
        product_url: { type: string, format: uri, nullable: true }
        in_possession: { type: boolean }
        ordered: { type: boolean }
        notes: { type: string, maxLength: 10000, nullable: true }
        image_source_url: { type: string, format: uri, nullable: true }
    GearItemResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/GearItem' }
    GearItemCollectionResponse:
      type: object
      required: [data, links, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/GearItem' }
        links: { type: object, additionalProperties: true }
        meta:
          type: object
          properties:
            current_page: { type: integer }
            last_page: { type: integer }
            per_page: { type: integer }
            total: { type: integer }
            summary: { $ref: '#/components/schemas/GearSummary' }
    GearSummary:
      type: object
      properties:
        item_rows: { type: integer }
        total_quantity: { type: integer }
        total_weight_grams: { type: integer }
        currency_code: { type: string, nullable: true }
        total_value_minor: { type: integer, nullable: true }
        values_by_currency: { type: object, additionalProperties: { type: integer } }
    ProductImportRequest:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri, description: Public HTTP(S) URL to import }
        category_id: { type: integer, minimum: 1, nullable: true }
        quantity: { type: integer, minimum: 1, maximum: 999, default: 1 }
        in_possession: { type: boolean, default: false }
        ordered: { type: boolean, default: false }
    ProductImport:
      type: object
      required: [id, url, status, quantity, in_possession, ordered]
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        status: { type: string, enum: [pending, processing, completed, failed] }
        failure_code: { type: string, nullable: true }
        failure_message: { type: string, nullable: true }
        result: { type: object, nullable: true, additionalProperties: true }
        gear_item_id: { type: integer, nullable: true }
        quantity: { type: integer }
        in_possession: { type: boolean }
        ordered: { type: boolean }
        started_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    ProductImportResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/ProductImport' }
    ProductImportCollectionResponse:
      type: object
      required: [data, links, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/ProductImport' }
        links: { type: object, additionalProperties: true }
        meta: { type: object, additionalProperties: true }
    HealthResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [status]
          properties:
            status: { type: string, enum: [ok] }
    MessageResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [message]
          properties:
            message: { type: string }
    ErrorResponse:
      type: object
      required: [message, code]
      properties:
        message: { type: string }
        code: { type: string }
    ValidationErrorResponse:
      allOf:
        - $ref: '#/components/schemas/ErrorResponse'
        - type: object
          required: [errors]
          properties:
            errors:
              type: object
              additionalProperties:
                type: array
                items: { type: string }
