openapi: 3.1.0
info:
  title: IP Geolocation API
  version: 1.0.0
  description: |
    Maps IPv4 addresses to country, region, city, latitude, longitude, and zip code.
    Powered by IP2Location DB9, refreshed bi-weekly. Internal MJH Life Sciences service.

    **CORS**: all `/api/*` responses set `Access-Control-Allow-Origin: *`. Pass the
    API key via the `Authorization: Bearer` (or `X-API-Key`) header — credentialed
    mode (`credentials: 'include'`) is not used.

    See [the full reference](https://github.com/MJHLS/ip.mjhlifesciences.com/blob/main/docs/api.md)
    for caching, performance, accuracy caveats, and operational details.
  contact:
    name: MJH Life Sciences
    url: https://github.com/MJHLS/ip.mjhlifesciences.com
  license:
    name: Internal use — MJH Life Sciences
servers:
  - url: https://ip.mjhlifesciences.com
    description: Production

tags:
  - name: geo
    description: IP geolocation lookups

# All endpoints under /api/* require an API key. Override per-operation
# with `security: []` for any future public endpoint.
security:
  - bearerAuth: []
  - apiKeyAuth: []

paths:
  /api/geo:
    get:
      tags: [geo]
      summary: Look up geolocation data for an IPv4 address
      description: |
        Returns country, region, city, lat/lon, and zip code for the given IPv4 address.

        If no `ip` query parameter is supplied, the caller's IP is auto-detected from
        request metadata (Vercel `clientAddress` → `x-forwarded-for` → `x-real-ip` →
        `cf-connecting-ip`).

        Responses are cached at the CDN for 24 hours per unique `ip` value.
      operationId: lookupGeo
      parameters:
        - name: ip
          in: query
          required: false
          schema:
            type: string
            format: ipv4
          description: |
            IPv4 dotted-quad notation (e.g. `8.8.8.8`).
            If omitted, the caller's IP is auto-detected.
          example: 8.8.8.8
      responses:
        '200':
          description: |
            Geo data found. Fields are `null` for IPs in reserved or unknown ranges.
          headers:
            Cache-Control:
              schema:
                type: string
                example: public, max-age=86400, s-maxage=86400
              description: 24-hour cache at CDN and clients.
            X-Vercel-Cache:
              schema:
                type: string
                enum: [HIT, MISS, BYPASS, STALE, REVALIDATED]
              description: Vercel CDN cache status (informational).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GeoResponse'
              examples:
                google_dns:
                  summary: Google DNS (8.8.8.8) — known range
                  value:
                    ip: 8.8.8.8
                    country_code: US
                    country_name: United States of America
                    region_name: California
                    city_name: Mountain View
                    latitude: 37.38605
                    longitude: -122.08385
                    zip_code: "94035"
                unknown_range:
                  summary: Reserved range (0.0.0.0) — fields null
                  value:
                    ip: 0.0.0.0
                    country_code: null
                    country_name: null
                    region_name: null
                    city_name: null
                    latitude: 0
                    longitude: 0
                    zip_code: null
                private_via_explicit_ip:
                  summary: RFC 1918 supplied via explicit ?ip= (caller bypasses guard)
                  value:
                    ip: 192.168.1.1
                    country_code: null
                    country_name: null
                    region_name: null
                    city_name: null
                    latitude: 0
                    longitude: 0
                    zip_code: null
        '400':
          description: |
            Malformed IPv4 address, IPv6 supplied, or auto-detected IP is private.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_format:
                  summary: Malformed input
                  value:
                    error: invalid_ip
                    message: '"foo" is not a valid IPv4 address.'
                ipv6:
                  summary: IPv6 supplied (this API is IPv4-only)
                  value:
                    error: invalid_ip
                    message: '"::1" is not a valid IPv4 address.'
                private_auto:
                  summary: Auto-detected client IP is private (use explicit ?ip= to override)
                  value:
                    error: private_ip
                    message: IP address is in a private or reserved range.
        '401':
          description: Missing or invalid API key.
          headers:
            WWW-Authenticate:
              schema:
                type: string
                example: 'Bearer realm="ip.mjhlifesciences.com"'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing:
                  value:
                    error: unauthorized
                    message: 'Missing API key. Provide via `Authorization: Bearer <key>` or `X-API-Key: <key>`.'
                invalid:
                  value:
                    error: unauthorized
                    message: Invalid or revoked API key.
        '429':
          description: |
            Rate limit exceeded for this credential. Each credential has an
            optional per-minute rate limit. Wait `Retry-After` seconds and
            retry, or contact the operations team to request a higher limit.
          headers:
            Retry-After:
              schema:
                type: integer
                example: 60
              description: Seconds to wait before retrying.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: rate_limited
                message: 'Rate limit exceeded (60/minute for this credential). Try again in 60s.'
                limit_per_minute: 60
        '404':
          description: IP falls outside every range in the dataset (very rare).
          content:
            application/json:
              schema:
                type: object
                required: [error, ip]
                properties:
                  error:
                    type: string
                    const: not_found
                  ip:
                    type: string
        '500':
          description: |
            Unexpected internal failure. The response body intentionally omits
            implementation details; the full error is reported to Sentry.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    const: internal
    head:
      tags: [geo]
      summary: Same headers as GET, no body
      description: HTTP HEAD is auto-routed to the GET handler; useful for cheap freshness checks.
      responses:
        '200':
          description: Same headers as the corresponding GET response.
    options:
      tags: [geo]
      summary: CORS preflight
      description: Returns the list of supported methods. No auth required.
      security: []
      responses:
        '204':
          description: No content.
          headers:
            Allow:
              schema:
                type: string
                example: 'GET, HEAD, OPTIONS'
            Cache-Control:
              schema:
                type: string
                example: public, max-age=86400
    post:
      tags: [geo]
      summary: Not supported — returns 405
      responses:
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
    put:
      tags: [geo]
      summary: Not supported — returns 405
      responses:
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
    delete:
      tags: [geo]
      summary: Not supported — returns 405
      responses:
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
    patch:
      tags: [geo]
      summary: Not supported — returns 405
      responses:
        '405':
          $ref: '#/components/responses/MethodNotAllowed'

components:
  schemas:
    GeoResponse:
      type: object
      required:
        - ip
        - country_code
        - country_name
        - region_name
        - city_name
        - latitude
        - longitude
        - zip_code
      properties:
        ip:
          type: string
          format: ipv4
          description: Echoes the IP that was looked up.
          example: 8.8.8.8
        country_code:
          type: [string, 'null']
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2. `null` for unknown ranges.
          example: US
        country_name:
          type: [string, 'null']
          description: English name.
          example: United States of America
        region_name:
          type: [string, 'null']
          description: State / province / region.
          example: California
        city_name:
          type: [string, 'null']
          description: City name.
          example: Mountain View
        latitude:
          type: number
          format: float
          description: Decimal degrees. `0` when unknown.
          example: 37.38605
        longitude:
          type: number
          format: float
          description: Decimal degrees. `0` when unknown.
          example: -122.08385
        zip_code:
          type: [string, 'null']
          description: Postal code. Format varies by country.
          example: "94035"
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Stable machine-readable error code.
          enum:
            - invalid_ip
            - private_ip
            - not_found
            - internal
            - method_not_allowed
            - unauthorized
            - rate_limited
        message:
          type: string
          description: Human-readable description (may be omitted on 500).
        ip:
          type: string
          description: Echoed input IP (present on 404).
        allowed:
          type: array
          description: Supported HTTP methods (present on 405).
          items:
            type: string
        limit_per_minute:
          type: integer
          description: Configured per-minute rate limit (present on 429).
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: |
        Provide your API key as a Bearer token:
        `Authorization: Bearer <your-key>`
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Alternative to Bearer. Provide your API key in the `X-API-Key` header.
  responses:
    MethodNotAllowed:
      description: Method not allowed.
      headers:
        Allow:
          schema:
            type: string
            example: 'GET, HEAD, OPTIONS'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: method_not_allowed
            allowed: [GET, HEAD, OPTIONS]

externalDocs:
  description: Full reference (Markdown)
  url: https://github.com/MJHLS/ip.mjhlifesciences.com/blob/main/docs/api.md
