openapi: 3.1.0
info:
  title: Lagoon API
  version: '1'
  description: |-
    Art metadata API. Search tagged artworks across platforms using structured
    parameters or plain English. Returns JSON with tags, artist handles,
    cross-platform links, and post metadata. No images are served; text
    metadata only.

    CORS is enabled for all origins. Every response includes an `ok` boolean.

    SFW filtering is on by default. All queries return only `general` and
    `sensitive` content unless `sfw=0` is passed or a specific `rating` is set.
  contact:
    name: Lagoon
    url: https://lagoon.io
    email: contact@lagoon.io

servers:
  - url: https://lagoon.io/api/v1

security: []

tags:
  - name: Search
    description: Full-text, tag, and natural language search.
  - name: Artist
    description: Cross-platform artist lookup.
  - name: Post
    description: Single post metadata.
  - name: Tags
    description: Tag index search.
  - name: Similar
    description: Semantic similarity search via vector embeddings.
  - name: Stats
    description: Index-wide statistics.

paths:
  /search:
    get:
      operationId: searchPosts
      summary: Search posts
      description: |-
        Find posts by natural language, full-text search, tag, artist, or
        platform. At least one of `nl`, `q`, `tag`, or `artist` is required.
        Results are ordered by relevance when `q` is used, and by date
        otherwise.
      tags: [Search]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: nl
          in: query
          schema:
            type: string
          description: |-
            Natural language query. The API extracts platform, artist, date
            range, tags, rating, sort order, and exclusions from the text.
            Leftover words become a full-text search. When combined with
            explicit parameters, the explicit parameter wins for the same
            field.
        - name: q
          in: query
          schema:
            type: string
          description: |-
            Full-text search against post titles, descriptions, and indexed
            content. Supports quoted phrases, `OR`, and `-exclusion`.
        - name: tag
          in: query
          schema:
            type: string
          description: |-
            Exact tag match in normalized form (e.g. `genshin_impact`). Use
            `/tags` to discover available tags.
        - name: artist
          in: query
          schema:
            type: string
          description: Artist handle or display name (case-insensitive pattern match).
        - name: platform
          in: query
          schema:
            type: string
            enum: [x, pixiv, bluesky, deviantart, artstation, newgrounds, tumblr, zerochan]
          description: Platform slug.
        - name: sfw
          in: query
          schema:
            type: string
          description: |-
            SFW filter. On by default (returns only `general` and `sensitive`
            rated posts). Set to `0` to include all ratings.
        - name: rating
          in: query
          schema:
            type: string
            enum: [general, sensitive, questionable, explicit]
          description: Filter to a specific rating. Overrides `sfw` if both are set.
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: Start date filter (YYYY-MM-DD).
        - name: to
          in: query
          schema:
            type: string
            format: date
          description: End date filter (YYYY-MM-DD).
        - name: sort
          in: query
          schema:
            type: string
            enum: [newest, oldest]
            default: newest
          description: Sort order by date. Default is newest first.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Results per page.
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Pagination offset.
      responses:
        '200':
          description: Search results.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, offset, results]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                    description: Number of results in this page.
                  offset:
                    type: integer
                  filters:
                    type: object
                    description: |-
                      Present only when the `nl` parameter is used. Shows what
                      the parser extracted from the natural language input.
                    additionalProperties: true
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/SearchResult'
              example:
                ok: true
                count: 1
                offset: 0
                results:
                  - id: 184201
                    source_url: https://x.com/artist/status/123456
                    title: character name (series) by artist
                    description: null
                    posted_at: '2025-11-15 08:30:00+00'
                    platform: x
                    platform_name: X (Twitter)
                    artist_handle: artist_name
                    artist_name: Artist Name
                    deleted: false
                    description_long: null
                    tags:
                      - 1girl
                      - blue hair
                      - 'character:name'
                      - 'copyright:series'
                      - 'rating:general'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientBalance'
        '429':
          $ref: '#/components/responses/RateLimited'

  /artist:
    get:
      operationId: lookupArtist
      summary: Look up an artist
      description: |-
        Look up an artist by handle and get all linked accounts across
        platforms.
      tags: [Artist]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: handle
          in: query
          required: true
          schema:
            type: string
          description: |-
            Artist handle to look up. When `platform` is set, must be the
            platform-specific handle (e.g. a Pixiv numeric user ID).
        - name: platform
          in: query
          schema:
            type: string
            enum: [x, pixiv, bluesky, deviantart, artstation, newgrounds, tumblr, zerochan]
          description: Narrow the handle match to a specific platform.
      responses:
        '200':
          description: Artist found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, artist]
                properties:
                  ok:
                    type: boolean
                    const: true
                  artist:
                    $ref: '#/components/schemas/Artist'
              example:
                ok: true
                artist:
                  handle: hews__
                  display_name: hews
                  post_count: 153
                  platforms:
                    - platform: x
                      platform_name: X (Twitter)
                      handle: hews__
                      display_name: hews
                      profile_url: https://x.com/hews__
                    - platform: pixiv
                      platform_name: Pixiv
                      handle: '4338012'
                      display_name: hews
                      profile_url: https://www.pixiv.net/users/4338012
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /post:
    get:
      operationId: getPost
      summary: Get a single post
      description: |-
        Get full metadata for a single post by ID. Returns `artist_profile`
        in addition to the fields present in search results. Posts where the
        original was removed by the artist have `deleted: true` and a null
        `source_url`. Tags and metadata remain available. The `/search`
        endpoint excludes deleted posts; `/post` returns them by ID.
      tags: [Post]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: integer
            minimum: 1
          description: Post ID.
      responses:
        '200':
          description: Post found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, post]
                properties:
                  ok:
                    type: boolean
                    const: true
                  post:
                    $ref: '#/components/schemas/PostDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /tags:
    get:
      operationId: searchTags
      summary: Search the tag index
      description: |-
        Search the tag index. Use this to discover available tags, build
        autocomplete, or check post counts. Results are sorted by category
        priority (character, copyright, then general) and then by post count
        within each category. Only tags with at least 3 posts are returned.
      tags: [Tags]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 2
          description: Tag name search (min 2 characters). Matches anywhere in the tag name.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
          description: Max results.
      responses:
        '200':
          description: Tag results.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, tags]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                  tags:
                    type: array
                    items:
                      $ref: '#/components/schemas/Tag'
              example:
                ok: true
                count: 3
                tags:
                  - name: 'character:ganyu (genshin impact)'
                    normalized: character_ganyu_genshin_impact
                    post_count: 2116
                    category: character
                  - name: 'character:mona (genshin impact)'
                    normalized: character_mona_genshin_impact
                    post_count: 1058
                    category: character
                  - name: 'character:lumine (genshin impact)'
                    normalized: character_lumine_genshin_impact
                    post_count: 982
                    category: character
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /similar:
    get:
      operationId: findSimilar
      summary: Find similar posts
      description: |-
        Find semantically similar posts using vector embeddings. Two modes:
        pass a post ID to find posts like it, or pass a text description for
        semantic search. At least one of `id` or `q` is required. Results are
        ranked by cosine similarity (0 to 1, where 1 is identical).
      tags: [Similar]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: query
          schema:
            type: integer
            minimum: 1
          description: Post ID to find similar posts for. Mutually exclusive with `q`.
        - name: q
          in: query
          schema:
            type: string
            minLength: 3
            maxLength: 500
          description: |-
            Text description to match semantically (3-500 characters).
            Mutually exclusive with `id`.
        - name: platform
          in: query
          schema:
            type: string
            enum: [x, pixiv, bluesky, deviantart, artstation, newgrounds, tumblr, zerochan]
          description: Filter results to a specific platform slug.
        - name: artist
          in: query
          schema:
            type: string
          description: Filter results to a specific artist handle (case-insensitive).
        - name: sfw
          in: query
          schema:
            type: string
          description: SFW filter, on by default. Set to `0` to include all ratings.
        - name: rating
          in: query
          schema:
            type: string
            enum: [general, sensitive, questionable, explicit]
          description: Filter to a specific rating. Overrides `sfw`.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
          description: Max results.
      responses:
        '200':
          description: Similar posts found.
          content:
            application/json:
              schema:
                type: object
                required: [ok, count, results]
                properties:
                  ok:
                    type: boolean
                    const: true
                  count:
                    type: integer
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/SimilarResult'
              example:
                ok: true
                count: 1
                results:
                  - id: 216477
                    source_url: https://www.pixiv.net/artworks/100389499
                    title: character (series) by artist
                    description: null
                    posted_at: '2022-10-24 17:23:58+00'
                    platform: pixiv
                    platform_name: Pixiv
                    artist_handle: '76910634'
                    artist_name: artist name
                    deleted: false
                    tags:
                      - 1girl
                      - blue hair
                      - sword
                    similarity: 0.8734
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Embedding service unavailable (text query mode only).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /stats:
    get:
      operationId: getStats
      summary: Get index statistics
      description: |-
        Returns index-wide statistics: total posts, artists, tags, and
        per-platform post counts. No parameters required.
      tags: [Stats]
      security:
        - {}
        - ApiKeyAuth: []
      parameters: []
      responses:
        '200':
          description: Index statistics.
          content:
            application/json:
              schema:
                type: object
                required: [ok, total_posts, total_artists, total_tags, platforms]
                properties:
                  ok:
                    type: boolean
                    const: true
                  total_posts:
                    type: integer
                  total_artists:
                    type: integer
                  total_tags:
                    type: integer
                    description: Tags with at least 3 posts.
                  platforms:
                    type: array
                    description: Per-platform breakdown sorted by post count descending.
                    items:
                      $ref: '#/components/schemas/PlatformStats'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |-
        API key for authenticated access. No key is needed for the free tier
        (30 requests per minute). Usage tier keys allow 120 rpm at $2 per
        1,000 requests. Pro tier keys allow 300 rpm at $25/month flat.

  schemas:
    PostBase:
      type: object
      description: Base post fields shared across endpoints.
      required: [id, deleted]
      properties:
        id:
          type: integer
          example: 184201
        source_url:
          type:
            - string
            - 'null'
          description: URL of the original post. Null when the post has been deleted by the artist.
        title:
          type:
            - string
            - 'null'
        description:
          type:
            - string
            - 'null'
          description: Short description from the source platform.
        posted_at:
          type:
            - string
            - 'null'
          format: date-time
          example: '2025-11-15 08:30:00+00'
        platform:
          type: string
          description: Platform slug.
          example: x
        platform_name:
          type: string
          description: Human-readable platform name.
          example: X (Twitter)
        artist_handle:
          type: string
          example: artist_name
        artist_name:
          type: string
          example: Artist Name
        deleted:
          type: boolean
          description: True when the original post was removed by the artist.

    SearchResult:
      description: Post returned by the search endpoint.
      allOf:
        - $ref: '#/components/schemas/PostBase'
        - type: object
          properties:
            description_long:
              type:
                - string
                - 'null'
              description: Extended description of the artwork.
            tags:
              type: array
              items:
                type: string
              description: |-
                Tag names including category-prefixed tags
                (e.g. `character:name`, `copyright:series`, `rating:general`).

    SimilarResult:
      description: Post returned by the similar endpoint, with a similarity score.
      allOf:
        - $ref: '#/components/schemas/PostBase'
        - type: object
          required: [similarity]
          properties:
            tags:
              type: array
              items:
                type: string
            similarity:
              type: number
              format: float
              minimum: 0
              maximum: 1
              description: Cosine similarity score. Values above 0.8 indicate strong similarity.
              example: 0.8734

    PostDetail:
      description: Full post metadata returned by the post endpoint.
      allOf:
        - $ref: '#/components/schemas/PostBase'
        - type: object
          properties:
            description_long:
              type:
                - string
                - 'null'
              description: Extended description of the artwork.
            artist_profile:
              type:
                - string
                - 'null'
              description: URL to the artist's profile on the source platform.
            tags:
              type: array
              items:
                type: string

    Tag:
      type: object
      required: [name, normalized, post_count, category]
      properties:
        name:
          type: string
          description: Display name of the tag.
          example: 'character:ganyu (genshin impact)'
        normalized:
          type: string
          description: Normalized form to pass to `/search?tag=`.
          example: character_ganyu_genshin_impact
        post_count:
          type: integer
          example: 2116
        category:
          type: string
          enum: [character, copyright, species, rating, general]

    Artist:
      type: object
      required: [handle, display_name, post_count, platforms]
      properties:
        handle:
          type: string
          example: hews__
        display_name:
          type: string
          example: hews
        post_count:
          type: integer
          description: Number of indexed posts by this artist.
          example: 153
        platforms:
          type: array
          description: All linked accounts across platforms.
          items:
            $ref: '#/components/schemas/ArtistPlatform'

    ArtistPlatform:
      type: object
      required: [platform, handle]
      properties:
        platform:
          type: string
          description: Platform slug.
          example: x
        platform_name:
          type: string
          description: Human-readable platform name.
          example: X (Twitter)
        handle:
          type: string
          example: hews__
        display_name:
          type: string
          example: hews
        profile_url:
          type:
            - string
            - 'null'
          description: URL to the artist's profile on this platform.
          example: https://x.com/hews__

    PlatformStats:
      type: object
      required: [slug, name, post_count]
      properties:
        slug:
          type: string
          example: pixiv
        name:
          type: string
          example: Pixiv
        post_count:
          type: integer
          description: Searchable posts on this platform (tagged, non-duplicate, non-deleted).
          example: 192480

    Error:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: string
          example: Rate limit exceeded. Max 30 requests per 60s.

  responses:
    BadRequest:
      description: Missing or invalid parameters.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: 'Provide at least one of: nl, q, tag, or artist.'
    Unauthorized:
      description: Invalid or expired API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: Invalid or expired API key.
    InsufficientBalance:
      description: Insufficient balance (Usage tier only).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: Insufficient balance. Add credit to your account to continue.
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: Post not found.
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            ok: false
            error: 'Rate limit exceeded. Max 30 requests per 60s.'
