{
  "openapi": "3.1.0",
  "info": {
    "title": "PetFoodFix API",
    "version": "1.1.0",
    "summary": "Read-only search over the PetFoodFix pet food label catalogue.",
    "description": "Public, read-only endpoints behind the PetFoodFix catalogue search. They return commercial pet food label data (ingredients, declared analytical constituents, kcal/100g) and the deterministic educational food-quality score for dog foods.\n\nBoundaries: results are educational label information, not veterinary advice. Cat products never carry a score. Values missing from the label are omitted or null, with one exception: when the pack declares no energy, `kcal` may be calculated from the declared analytical constituents (see the `kcal` field). Feeding guidance for puppies, pregnant or lactating animals, or animals with a medical condition is out of scope.\n\nNo authentication. Errors are JSON with `error`, `code` and `hint`. Every endpoint is rate limited per client IP (IPv6: per /64 prefix) and returns IETF `RateLimit-Policy` / `RateLimit` headers (draft-ietf-httpapi-ratelimit-headers) plus `Retry-After` on 429.",
    "termsOfService": "https://petfoodfix.com/legal/terms",
    "contact": {
      "name": "PetFoodFix",
      "url": "https://petfoodfix.com/contact",
      "email": "petfoodfix@lookadev.com"
    }
  },
  "externalDocs": {
    "description": "PetFoodFix API documentation",
    "url": "https://petfoodfix.com/developers"
  },
  "servers": [{ "url": "https://petfoodfix.com" }],
  "security": [],
  "tags": [{ "name": "catalogue", "description": "Pet food products and brands" }],
  "paths": {
    "/api/foods/products/search": {
      "get": {
        "operationId": "searchFoodProducts",
        "tags": ["catalogue"],
        "summary": "Search pet food products by brand and product name",
        "description": "Free-text search over active products in one market. Returns up to 20 products ranked by match, with label data and, for dog foods, the educational score. When fewer than 5 products match, near-miss spellings are appended to `products` (`matchType` `strong_similar` or `possible_match`) and also listed in `fuzzySuggestions`, for the user to confirm; never treat them as the product the user meant. `matchType` `exact` means the product name contains the query text.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "description": "Brand and/or product name, e.g. `royal canin giant puppy`. Empty returns a filtered list.",
            "schema": { "type": "string", "maxLength": 120 }
          },
          {
            "name": "market",
            "in": "query",
            "description": "Catalogue market. Any value not in this list falls back to `uk`.",
            "schema": { "type": "string", "enum": ["uk", "br", "us", "ca", "pt", "other"], "default": "uk" }
          },
          {
            "name": "species",
            "in": "query",
            "description": "Animal the food is for. Cat results never include a score.",
            "schema": { "type": "string", "enum": ["dog", "cat"], "default": "dog" }
          },
          {
            "name": "brand",
            "in": "query",
            "description": "Keep only brands whose name contains this text (case-insensitive).",
            "schema": { "type": "string", "maxLength": 80 }
          },
          {
            "name": "format",
            "in": "query",
            "description": "Exact food format as stored, e.g. `dry` or `wet`.",
            "schema": { "type": "string" }
          },
          {
            "name": "life_stage",
            "in": "query",
            "description": "Exact life stage as stored, e.g. `adult`, `puppy`, `senior`.",
            "schema": { "type": "string" }
          },
          {
            "name": "completeness",
            "in": "query",
            "description": "Exact completeness statement as stored, e.g. `complete` or `complementary`.",
            "schema": { "type": "string" }
          },
          {
            "name": "verification_status",
            "in": "query",
            "description": "Exact data verification status, e.g. `verified` or `needs_review`.",
            "schema": { "type": "string" }
          },
          {
            "name": "band",
            "in": "query",
            "description": "Keep only dog foods in this score band.",
            "schema": { "type": "string", "enum": ["very_good", "good", "regular", "bad", "insufficient_data"] }
          },
          {
            "name": "min_confidence",
            "in": "query",
            "description": "Minimum label data confidence (0-100).",
            "schema": { "type": "integer", "minimum": 0, "maximum": 100 }
          },
          {
            "name": "products",
            "in": "query",
            "description": "Comma-separated product UUIDs (at most 25) to restrict the result to.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching products.",
            "headers": {
              "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" },
              "RateLimit": { "$ref": "#/components/headers/RateLimit" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/SearchResponse" } }
            }
          },
          "400": {
            "description": "Invalid parameter. `code` is one of `query_too_long`, `invalid_min_confidence`, `invalid_product_filter`, `brand_filter_too_long`.",
            "headers": {
              "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" },
              "RateLimit": { "$ref": "#/components/headers/RateLimit" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/foods/brands": {
      "get": {
        "operationId": "listDogFoodBrands",
        "tags": ["catalogue"],
        "summary": "List dog food brands",
        "description": "Up to 20 brands, alphabetical, that have at least one active dog food. Use the returned `id` with `listBrandDogFoods`.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "description": "Keep only brands whose name contains this text (case-insensitive).",
            "schema": { "type": "string", "maxLength": 50 }
          }
        ],
        "responses": {
          "200": {
            "description": "Brands.",
            "headers": {
              "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" },
              "RateLimit": { "$ref": "#/components/headers/RateLimit" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/BrandList" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/api/foods/products": {
      "get": {
        "operationId": "listBrandDogFoods",
        "tags": ["catalogue"],
        "summary": "List the dog foods of one brand",
        "description": "Up to 20 active dog foods of a brand, alphabetical by product name.",
        "parameters": [
          {
            "name": "brandId",
            "in": "query",
            "required": true,
            "description": "Brand UUID from `listDogFoodBrands`.",
            "schema": { "type": "string", "format": "uuid" }
          },
          {
            "name": "q",
            "in": "query",
            "description": "Keep only products whose name contains this text (case-insensitive).",
            "schema": { "type": "string", "maxLength": 50 }
          }
        ],
        "responses": {
          "200": {
            "description": "Products of the brand.",
            "headers": {
              "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" },
              "RateLimit": { "$ref": "#/components/headers/RateLimit" }
            },
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/BrandProductList" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RateLimit-Policy": {
        "description": "Quota policy, e.g. `\"product-search\";q=120;w=900` (120 requests per 900 seconds per client IP).",
        "schema": { "type": "string" }
      },
      "RateLimit": {
        "description": "Remaining quota, e.g. `\"product-search\";r=119`; `t` (seconds until quota returns) is added when throttled.",
        "schema": { "type": "string" }
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying.",
        "schema": { "type": "integer", "minimum": 1 }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid parameter.",
        "headers": {
          "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" },
          "RateLimit": { "$ref": "#/components/headers/RateLimit" }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Rate limit reached (`code`: `rate_limited`). Wait `Retry-After` seconds.",
        "headers": {
          "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" },
          "RateLimit": { "$ref": "#/components/headers/RateLimit" },
          "Retry-After": { "$ref": "#/components/headers/Retry-After" }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "InternalError": {
        "description": "Unexpected server error (`code`: `internal_error`). Retry later.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Error body of these endpoints and of unknown /api paths (`code`: `not_found`). A method other than GET on a documented path gets an empty 405.",
        "required": ["error", "code"],
        "properties": {
          "error": { "type": "string", "description": "Human-readable message." },
          "code": { "type": "string", "description": "Stable machine-readable error code.", "examples": ["query_too_long"] },
          "hint": { "type": "string", "description": "How to fix the request." }
        }
      },
      "SearchResponse": {
        "type": "object",
        "required": ["products", "searchMethod", "searchVersion"],
        "properties": {
          "products": { "type": "array", "maxItems": 20, "items": { "$ref": "#/components/schemas/SearchResult" } },
          "fuzzySuggestions": {
            "type": "array",
            "description": "Near-miss matches for the user to confirm. Absent when a cat query is blocked.",
            "items": { "$ref": "#/components/schemas/FuzzySuggestion" }
          },
          "searchMethod": { "type": "string", "enum": ["combined", "filter", "cat_normalized", "cat_normalized_blocked"] },
          "searchVersion": { "type": "string", "description": "Version of the search algorithm." },
          "rankingVersion": { "type": "string", "description": "Version of the match ranking (dog searches only)." }
        }
      },
      "FuzzySuggestion": {
        "type": "object",
        "required": ["product", "similarity", "strength"],
        "properties": {
          "product": { "$ref": "#/components/schemas/SearchResult" },
          "similarity": { "type": "number", "minimum": 0, "maximum": 1 },
          "strength": { "type": "string", "enum": ["strong", "possible", "none"] }
        }
      },
      "SearchResult": {
        "type": "object",
        "required": [
          "id", "brandId", "brandName", "productName", "format", "lifeStage", "confidence", "matchType",
          "similarityScore", "market", "species", "verificationStatus", "completeness", "hasRetailerLinks"
        ],
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "brandId": { "type": "string", "format": "uuid" },
          "brandName": { "type": "string" },
          "productName": { "type": "string" },
          "slug": { "type": "string", "description": "Product page slug, e.g. /uk/dogs/foods/{slug} or /br/caes/racoes/{slug}." },
          "format": { "type": "string", "examples": ["dry"] },
          "lifeStage": { "type": "string", "examples": ["adult"] },
          "confidence": { "type": "integer", "minimum": 0, "maximum": 100, "description": "Label data confidence." },
          "matchType": { "type": "string", "enum": ["exact", "strong_similar", "possible_match"] },
          "similarityScore": { "type": "number", "minimum": 0, "maximum": 1 },
          "market": { "type": "string" },
          "species": { "type": "string", "enum": ["dog", "cat"] },
          "verificationStatus": { "type": "string" },
          "completeness": { "type": "string" },
          "score": { "type": "integer", "minimum": 0, "maximum": 100, "description": "Educational food-quality score (dog foods only)." },
          "band": { "type": "string", "description": "Score band (dog foods only)." },
          "compositionScore": { "type": "integer", "minimum": 0, "maximum": 100 },
          "compositionBand": { "type": "string" },
          "dataConfidenceScore": { "type": "integer", "minimum": 0, "maximum": 100 },
          "scoreSource": { "type": "string", "enum": ["v2_computed", "persisted_legacy", "insufficient_data"] },
          "scoreRuleVersion": { "type": "string", "description": "Scoring rule version that produced the score." },
          "ingredientsRaw": { "type": "string", "description": "Ingredient list as printed on the label." },
          "proteinPct": { "type": "number" },
          "fatPct": { "type": "number" },
          "fibrePct": { "type": "number" },
          "ashPct": { "type": "number" },
          "moisturePct": { "type": "number" },
          "kcal": { "type": "number", "description": "kcal per 100 g. As declared on the pack or, when the pack declares no energy, calculated with modified Atwater factors (3.5/8.5/3.5) from the declared protein, fat, fibre, ash and moisture, assuming 10% moisture, 3% fibre and 8% ash when those are not declared. The response does not say which." },
          "hasRetailerLinks": { "type": "boolean" }
        }
      },
      "BrandList": {
        "type": "object",
        "required": ["brands"],
        "properties": {
          "brands": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "object",
              "required": ["id", "name"],
              "properties": {
                "id": { "type": "string", "format": "uuid" },
                "name": { "type": "string" }
              }
            }
          }
        }
      },
      "BrandProductList": {
        "type": "object",
        "required": ["products"],
        "properties": {
          "products": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "object",
              "required": ["id", "brandId", "brandName", "productName", "foodType", "lifeStage", "completenessStatement", "confidence"],
              "properties": {
                "id": { "type": "string", "format": "uuid" },
                "brandId": { "type": "string", "format": "uuid" },
                "brandName": { "type": "string" },
                "productName": { "type": "string" },
                "foodType": { "type": "string" },
                "lifeStage": { "type": "string" },
                "completenessStatement": { "type": "string" },
                "confidence": { "type": "integer", "minimum": 0, "maximum": 100 },
                "proteinPercent": { "type": ["string", "null"], "description": "Declared crude protein %, as a decimal string; null when not on the label." },
                "finalClassification": { "type": "null" }
              }
            }
          }
        }
      }
    }
  }
}
