From a3b397d1aea5c8ec69ce94f762bdac5f924db90d Mon Sep 17 00:00:00 2001 From: Imron Reviady Date: Sat, 3 Oct 2026 19:38:25 +0700 Subject: [PATCH] Pin API contract 1.0.0 and check every call against it The SDK now states the API contract version it was validated against: a CONTRACT_VERSION file, a public constant and a README line, with the contract itself committed under contract/openapi-1.0.0.json (the backend repository is private, so CI cannot download it). A contract test calls every public method with a stubbed transport and checks that each request's method and path exist in the pinned contract and that every field the contract marks required is sent. A guard fails when a public method is added without being covered by the check. --- CONTRACT_VERSION | 1 + README.md | 2 + client.go | 5 + contract/openapi-1.0.0.json | 5341 +++++++++++++++++++++++++++++++++++ contract_test.go | 348 +++ 5 files changed, 5697 insertions(+) create mode 100644 CONTRACT_VERSION create mode 100644 contract/openapi-1.0.0.json create mode 100644 contract_test.go diff --git a/CONTRACT_VERSION b/CONTRACT_VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/CONTRACT_VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/README.md b/README.md index e6cd65a..78d1c1f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ go get github.com/livexface/livexface-go Requires Go 1.22 or later. +Validated against API contract 1.0.0 (`/openapi.json` `info.version`), exposed as `livexface.ContractVersion`. The test suite calls every client method against the pinned contract in `contract/` and fails if a method or path is missing from it or a required field is not sent; to move to a new contract, copy the release asset `openapi-.json` into `contract/` and update `CONTRACT_VERSION` and the `ContractVersion` constant. + ## Quick Start ```go diff --git a/client.go b/client.go index c779f1b..70df154 100644 --- a/client.go +++ b/client.go @@ -20,6 +20,11 @@ import ( const defaultBaseURL = "https://api.livexface.com/api/v1" +// ContractVersion is the API contract version (info.version of /openapi.json) +// this SDK release is validated against. The contract is pinned in +// contract/openapi-.json and checked by the test suite. +const ContractVersion = "1.0.0" + // ─── Error ──────────────────────────────────────────────────────────────────── // APIError is returned when the server responds with a non-successful payload. diff --git a/contract/openapi-1.0.0.json b/contract/openapi-1.0.0.json new file mode 100644 index 0000000..3743d0d --- /dev/null +++ b/contract/openapi-1.0.0.json @@ -0,0 +1,5341 @@ +{ + "components": { + "schemas": { + "github_com_livexface_backend_internal_domain_entity.ActiveLivenessResult": { + "properties": { + "challenges": { + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "type": "object" + }, + "framesAnalyzed": { + "type": "integer" + }, + "framesWithFace": { + "type": "integer" + }, + "isLive": { + "type": "boolean" + }, + "livenessToken": { + "description": "Set only when the check passed and the token could be stored.", + "type": "string" + }, + "livenessTokenExpiresAt": { + "type": "string" + }, + "overallScore": { + "type": "number" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.BatchJobResult": { + "properties": { + "error": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "faceId": { + "type": "string" + }, + "index": { + "type": "integer" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.DetectedFace": { + "properties": { + "bbox": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.FaceBBox" + }, + "detScore": { + "type": "number" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.FaceBBox": { + "properties": { + "height": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.ImageSize": { + "properties": { + "height": { + "type": "integer" + }, + "width": { + "type": "integer" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.PlanFeature": { + "properties": { + "included": { + "type": "boolean" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.PlanInfo": { + "properties": { + "annualPrice": { + "type": "string" + }, + "annualTotal": { + "type": "string" + }, + "badge": { + "type": "string" + }, + "cta": { + "type": "string" + }, + "ctaHref": { + "type": "string" + }, + "description": { + "type": "string" + }, + "features": { + "items": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.PlanFeature" + }, + "type": "array" + }, + "highlight": { + "type": "boolean" + }, + "key": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.PlanType" + }, + "limits": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.PlanLimits" + }, + "name": { + "type": "string" + }, + "period": { + "type": "string" + }, + "price": { + "type": "string" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.PlanLimits": { + "properties": { + "maxApiKeysPerMember": { + "type": "integer" + }, + "maxFaceCollections": { + "type": "integer" + }, + "maxFacesPerCollection": { + "type": "integer" + }, + "maxMembers": { + "type": "integer" + }, + "maxOrgsOwned": { + "description": "MaxOrgsOwned limits how many organizations a single user account can own.\nEnforced at org creation time against the user's best existing plan.", + "type": "integer" + }, + "monthlyUnits": { + "description": "MonthlyUnits caps faces processed per calendar month (metering units).\n0 = unlimited. Deliberately 0 for every plan until unit pricing is final —\nthe enforcement plumbing (middleware.UnitQuota) is wired but dormant.", + "type": "integer" + }, + "rateLimitRpm": { + "type": "integer" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_entity.PlanType": { + "enum": [ + "free", + "starter", + "pro", + "enterprise" + ], + "type": "string", + "x-enum-varnames": [ + "PlanFree", + "PlanStarter", + "PlanPro", + "PlanEnterprise" + ] + }, + "github_com_livexface_backend_internal_domain_service.DetectionResult": { + "properties": { + "confidence": { + "type": "number" + }, + "detected": { + "type": "boolean" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_service.EmotionResult": { + "properties": { + "confidence": { + "type": "number" + }, + "label": { + "type": "string" + }, + "scores": { + "additionalProperties": { + "format": "float32", + "type": "number" + }, + "type": "object" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_domain_service.HeadPose": { + "properties": { + "frontalScore": { + "type": "number" + }, + "pitch": { + "type": "number" + }, + "roll": { + "type": "number" + }, + "yaw": { + "type": "number" + } + }, + "type": "object" + }, + "github_com_livexface_backend_internal_usecase.InvoiceDTO": { + "properties": { + "amountPaid": { + "description": "cents", + "type": "integer" + }, + "created": { + "type": "integer" + }, + "currency": { + "type": "string" + }, + "hostedInvoiceUrl": { + "type": "string" + }, + "id": { + "type": "string" + }, + "invoicePdf": { + "type": "string" + }, + "number": { + "type": "string" + }, + "periodEnd": { + "type": "integer" + }, + "periodStart": { + "description": "unix timestamp", + "type": "integer" + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.APIError": { + "properties": { + "code": { + "type": "string" + }, + "details": { + "description": "Details carries machine-readable context for codes that define it\n(MULTIPLE_FACES: faceCount and faces). Omitted otherwise." + }, + "message": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.APIResponse": { + "properties": { + "data": {}, + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + }, + "message": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "timestamp": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.activateRequest": { + "properties": { + "activation_key": { + "type": "string" + }, + "hw_signals": { + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "activation_key", + "node_id" + ], + "type": "object" + }, + "internal_delivery_http_handler.activateResponse": { + "properties": { + "license": { + "description": "signed license JWT", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.apiKeyDTO": { + "properties": { + "allowedIps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "createdAt": { + "type": "string" + }, + "customRateLimitRpm": { + "type": "integer" + }, + "expiresAt": { + "type": "string" + }, + "id": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "isActive": { + "example": true, + "type": "boolean" + }, + "isSandbox": { + "type": "boolean" + }, + "keyPrefix": { + "example": "lxf_a1b2c3d4", + "type": "string" + }, + "lastUsedAt": { + "type": "string" + }, + "name": { + "example": "production-key", + "type": "string" + }, + "scopes": { + "example": [ + "face:read", + "face:write" + ], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.auditDTO": { + "properties": { + "action": { + "type": "string" + }, + "actorEmail": { + "type": "string" + }, + "actorId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "details": { + "type": "string" + }, + "id": { + "type": "string" + }, + "targetId": { + "type": "string" + }, + "targetType": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.auditListResponse": { + "properties": { + "auditLogs": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.auditDTO" + }, + "type": "array" + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.authResponse": { + "properties": { + "accessToken": { + "example": "eyJhbGciOiJIUzI1NiIs...", + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "tokenType": { + "example": "Bearer", + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/internal_delivery_http_handler.userDTO" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.batchDeleteRequest": { + "properties": { + "faceIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "faceIds" + ], + "type": "object" + }, + "internal_delivery_http_handler.batchDeleteResponse": { + "properties": { + "failed": { + "type": "integer" + }, + "results": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.batchDeleteResult" + }, + "type": "array" + }, + "succeeded": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.batchDeleteResult": { + "properties": { + "error": { + "type": "string" + }, + "faceId": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.batchFaceResult": { + "properties": { + "error": { + "type": "string" + }, + "externalId": { + "type": "string" + }, + "face": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceDTO" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.batchJobDTO": { + "properties": { + "collectionId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "failed": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "processed": { + "type": "integer" + }, + "results": { + "items": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.BatchJobResult" + }, + "type": "array" + }, + "status": { + "type": "string" + }, + "succeeded": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.batchResponse": { + "properties": { + "failed": { + "type": "integer" + }, + "results": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.batchFaceResult" + }, + "type": "array" + }, + "succeeded": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.billingResponse": { + "properties": { + "plan": { + "type": "string" + }, + "planLimits": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.PlanLimits" + }, + "usage": { + "$ref": "#/components/schemas/internal_delivery_http_handler.usageNumbers" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.changePasswordRequest": { + "properties": { + "newPassword": { + "example": "newpass456", + "minLength": 8, + "type": "string" + }, + "oldPassword": { + "example": "oldpass123", + "type": "string" + } + }, + "required": [ + "newPassword", + "oldPassword" + ], + "type": "object" + }, + "internal_delivery_http_handler.checkoutRequest": { + "properties": { + "billingCycle": { + "description": "\"monthly\" or \"annual\"", + "example": "monthly", + "type": "string" + }, + "deploymentMode": { + "description": "\"cloud\" (default) or \"on_prem\"", + "example": "cloud", + "type": "string" + }, + "planType": { + "example": "starter", + "type": "string" + } + }, + "required": [ + "planType" + ], + "type": "object" + }, + "internal_delivery_http_handler.checkoutResponse": { + "properties": { + "checkoutUrl": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.collectionDTO": { + "properties": { + "createdAt": { + "type": "string" + }, + "description": { + "example": "Employee face database", + "type": "string" + }, + "faceCount": { + "example": 42, + "type": "integer" + }, + "faceSelection": { + "example": "center", + "type": "string" + }, + "id": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "name": { + "example": "employees", + "type": "string" + }, + "organizationId": { + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": "string" + }, + "requireLiveness": { + "example": true, + "type": "boolean" + }, + "retentionDays": { + "example": 90, + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.createCollectionRequest": { + "properties": { + "description": { + "example": "Employee face database", + "maxLength": 500, + "type": "string" + }, + "faceSelection": { + "description": "FaceSelection: center (default), largest or single.", + "enum": [ + "center", + "largest", + "single" + ], + "example": "center", + "type": "string" + }, + "name": { + "example": "employees", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "requireLiveness": { + "description": "RequireLiveness makes enrolment require a liveness token. Optional;\ndefaults to false.", + "example": true, + "type": "boolean" + }, + "retentionDays": { + "description": "optional; nil = retain forever", + "example": 90, + "type": "integer" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "internal_delivery_http_handler.createKeyRequest": { + "properties": { + "allowedIps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "customRateLimitRpm": { + "type": "integer" + }, + "expiresAt": { + "type": "string" + }, + "isSandbox": { + "type": "boolean" + }, + "name": { + "example": "production-key", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "scopes": { + "example": [ + "face:read", + "face:write" + ], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "internal_delivery_http_handler.createKeyResponse": { + "properties": { + "allowedIps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "createdAt": { + "type": "string" + }, + "customRateLimitRpm": { + "type": "integer" + }, + "expiresAt": { + "type": "string" + }, + "id": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "isActive": { + "example": true, + "type": "boolean" + }, + "isSandbox": { + "type": "boolean" + }, + "key": { + "description": "Shown once only!", + "example": "lxf_a1b2c3d4_e5f6g7h8i9j0k1l2m3n4o5p6", + "type": "string" + }, + "keyPrefix": { + "example": "lxf_a1b2c3d4", + "type": "string" + }, + "lastUsedAt": { + "type": "string" + }, + "name": { + "example": "production-key", + "type": "string" + }, + "scopes": { + "example": [ + "face:read", + "face:write" + ], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.createOrgRequest": { + "properties": { + "name": { + "example": "Acme Corp", + "maxLength": 100, + "minLength": 2, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "internal_delivery_http_handler.createWebhookRequest": { + "properties": { + "events": { + "example": [ + "member.invited", + "api_key.created" + ], + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "url": { + "example": "https://example.com/webhook", + "type": "string" + } + }, + "required": [ + "events", + "url" + ], + "type": "object" + }, + "internal_delivery_http_handler.crossSearchMatch": { + "properties": { + "collectionId": { + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": "string" + }, + "confidence": { + "example": 0.951, + "type": "number" + }, + "externalId": { + "example": "alice_001", + "type": "string" + }, + "faceId": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "metadata": {} + }, + "type": "object" + }, + "internal_delivery_http_handler.crossSearchResponse": { + "properties": { + "collectionsSearched": { + "example": 3, + "type": "integer" + }, + "detectedFaces": { + "items": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.DetectedFace" + }, + "type": "array" + }, + "imageSize": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.ImageSize" + }, + "matches": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.crossSearchMatch" + }, + "type": "array" + }, + "queryTimeMs": { + "example": 31, + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.currentPeriodDTO": { + "properties": { + "limit": { + "type": "integer" + }, + "requests": { + "type": "integer" + }, + "resetAt": { + "type": "string" + }, + "units": { + "description": "faces processed this period (metering unit)", + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.dailyUsageDTO": { + "properties": { + "date": { + "type": "string" + }, + "failedRequests": { + "type": "integer" + }, + "successfulRequests": { + "type": "integer" + }, + "totalRequests": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.demoAnalysisResponse": { + "properties": { + "attributes": { + "$ref": "#/components/schemas/internal_delivery_http_handler.demoAttributes" + }, + "liveness": { + "$ref": "#/components/schemas/internal_delivery_http_handler.demoLiveness" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.demoAttributes": { + "properties": { + "faceCount": { + "example": 1, + "type": "integer" + }, + "faceDetected": { + "example": true, + "type": "boolean" + }, + "primary": { + "$ref": "#/components/schemas/internal_delivery_http_handler.demoFaceAttribs" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.demoFaceAttribs": { + "properties": { + "age": { + "example": 28, + "type": "integer" + }, + "emotion": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.EmotionResult" + }, + "gender": { + "example": "female", + "type": "string" + }, + "glasses": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.DetectionResult" + }, + "headPose": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.HeadPose" + }, + "mask": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.DetectionResult" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.demoLiveness": { + "properties": { + "faceCount": { + "example": 1, + "type": "integer" + }, + "faceDetected": { + "example": true, + "type": "boolean" + }, + "isLive": { + "example": true, + "type": "boolean" + }, + "livenessScore": { + "example": 0.97, + "type": "number" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.depStatus": { + "properties": { + "error": { + "type": "string" + }, + "latency": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "description": "\"ok\" or \"error\"", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.faceAttributesFaceDTO": { + "properties": { + "age": { + "example": 28, + "type": "integer" + }, + "bbox": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.FaceBBox" + }, + "detScore": { + "example": 0.9821, + "type": "number" + }, + "emotion": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.EmotionResult" + }, + "gender": { + "example": "female", + "type": "string" + }, + "glasses": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.DetectionResult" + }, + "headPose": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.HeadPose" + }, + "landmarks106": { + "items": { + "items": { + "format": "float32", + "type": "number" + }, + "type": "array" + }, + "type": "array" + }, + "landmarks5pt": { + "items": { + "items": { + "format": "float32", + "type": "number" + }, + "type": "array" + }, + "type": "array" + }, + "mask": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_service.DetectionResult" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.faceAttributesResponse": { + "properties": { + "faceCount": { + "example": 1, + "type": "integer" + }, + "faceDetected": { + "example": true, + "type": "boolean" + }, + "faces": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceAttributesFaceDTO" + }, + "type": "array" + }, + "imageSize": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.ImageSize" + }, + "primary": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceAttributesFaceDTO" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.faceDTO": { + "properties": { + "collectionId": { + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "externalId": { + "example": "alice_001", + "type": "string" + }, + "id": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "imageUrl": { + "example": "https://storage.example.com/faces/alice.jpg", + "type": "string" + }, + "metadata": {} + }, + "type": "object" + }, + "internal_delivery_http_handler.faceListResponse": { + "properties": { + "faces": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceDTO" + }, + "type": "array" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.forgotPasswordRequest": { + "properties": { + "email": { + "example": "user@example.com", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "internal_delivery_http_handler.heartbeatRequest": { + "properties": { + "license_id": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "units_total": { + "description": "lifetime faces processed (telemetry; optional)", + "type": "integer" + } + }, + "required": [ + "license_id", + "node_id" + ], + "type": "object" + }, + "internal_delivery_http_handler.heartbeatResponse": { + "properties": { + "status": { + "description": "\"ok\" — errors use the standard error envelope", + "type": "string" + }, + "term_end": { + "description": "RFC 3339; moves forward when the subscription renews", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.identifyMatch": { + "properties": { + "confidence": { + "example": 0.951, + "type": "number" + }, + "externalId": { + "example": "alice_001", + "type": "string" + }, + "faceId": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "metadata": {} + }, + "type": "object" + }, + "internal_delivery_http_handler.identifyResponse": { + "properties": { + "detectedFaces": { + "items": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.DetectedFace" + }, + "type": "array" + }, + "imageSize": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.ImageSize" + }, + "matches": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.identifyMatch" + }, + "type": "array" + }, + "queryTimeMs": { + "example": 23, + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.invitationDTO": { + "properties": { + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "role": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.invitationPreviewDTO": { + "properties": { + "email": { + "example": "new.colleague@example.com", + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "inviterName": { + "example": "Alice Smith", + "type": "string" + }, + "organizationName": { + "example": "Acme Corp", + "type": "string" + }, + "role": { + "example": "developer", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.inviteMemberRequest": { + "properties": { + "email": { + "example": "alice@example.com", + "type": "string" + }, + "role": { + "example": "developer", + "type": "string" + }, + "userId": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + }, + "internal_delivery_http_handler.livenessResponse": { + "properties": { + "faceCount": { + "type": "integer" + }, + "faceDetected": { + "type": "boolean" + }, + "isLive": { + "type": "boolean" + }, + "livenessScore": { + "type": "number" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.loginRequest": { + "properties": { + "email": { + "example": "alice@example.com", + "type": "string" + }, + "password": { + "example": "secret123", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "type": "object" + }, + "internal_delivery_http_handler.meStatsResponse": { + "properties": { + "activeSessions": { + "type": "integer" + }, + "orgsJoined": { + "type": "integer" + }, + "orgsOwned": { + "type": "integer" + }, + "twoFactorEnabled": { + "type": "boolean" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.memberDTO": { + "properties": { + "email": { + "type": "string" + }, + "fullName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "joinedAt": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "role": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.orgDTO": { + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "name": { + "example": "Acme Corp", + "type": "string" + }, + "ownerId": { + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": "string" + }, + "planType": { + "example": "free", + "type": "string" + }, + "slug": { + "example": "acme-corp", + "type": "string" + }, + "subscriptionStatus": { + "example": "none", + "type": "string" + }, + "userRole": { + "example": "owner", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.orgStatsResponse": { + "properties": { + "activeKeys": { + "type": "integer" + }, + "apiKeys": { + "type": "integer" + }, + "collections": { + "type": "integer" + }, + "members": { + "type": "integer" + }, + "requestsThisMonth": { + "type": "integer" + }, + "requestsToday": { + "type": "integer" + }, + "totalFaces": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.portalResponse": { + "properties": { + "portalUrl": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.quotaDTO": { + "properties": { + "collectionLimit": { + "type": "integer" + }, + "collections": { + "type": "integer" + }, + "faceLimit": { + "type": "integer" + }, + "faces": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.referralStatsResponse": { + "properties": { + "paidConversions": { + "type": "integer" + }, + "referralCode": { + "type": "string" + }, + "shareUrl": { + "type": "string" + }, + "totalSignups": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.registerRequest": { + "properties": { + "email": { + "example": "alice@example.com", + "type": "string" + }, + "fullName": { + "example": "Alice Smith", + "minLength": 2, + "type": "string" + }, + "inviteToken": { + "description": "InviteToken comes from an invitation link. The address must be the one\ninvited; the account is then verified and joins the organization.", + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "type": "string" + }, + "password": { + "example": "secret123", + "minLength": 8, + "type": "string" + }, + "referralCode": { + "example": "ABCD1234", + "type": "string" + } + }, + "required": [ + "email", + "fullName", + "password" + ], + "type": "object" + }, + "internal_delivery_http_handler.resendVerificationRequest": { + "properties": { + "email": { + "example": "user@example.com", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "internal_delivery_http_handler.resetPasswordRequest": { + "properties": { + "newPassword": { + "example": "newpassword123", + "minLength": 8, + "type": "string" + }, + "token": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + } + }, + "required": [ + "newPassword", + "token" + ], + "type": "object" + }, + "internal_delivery_http_handler.rotateSecretResponse": { + "properties": { + "id": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "secret": { + "example": "whsec_3f2a...", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.sessionDTO": { + "properties": { + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ipAddress": { + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.setup2FAResponse": { + "properties": { + "message": { + "type": "string" + }, + "qrCodeBase64": { + "type": "string" + }, + "secret": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.subscriptionDTO": { + "properties": { + "cancelAtPeriodEnd": { + "type": "boolean" + }, + "canceledAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "currentPeriodEnd": { + "type": "string" + }, + "currentPeriodStart": { + "type": "string" + }, + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "planType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "stripeSubscriptionId": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.topApiKeyDTO": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "percentage": { + "type": "number" + }, + "requests": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.topCollectionDTO": { + "properties": { + "faceCount": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.transferOwnershipRequest": { + "properties": { + "newOwnerId": { + "type": "string" + } + }, + "required": [ + "newOwnerId" + ], + "type": "object" + }, + "internal_delivery_http_handler.updateCollectionRequest": { + "properties": { + "description": { + "example": "Updated description", + "maxLength": 500, + "type": "string" + }, + "faceSelection": { + "description": "Omitting it leaves the current policy alone.", + "enum": [ + "center", + "largest", + "single" + ], + "example": "single", + "type": "string" + }, + "name": { + "example": "employees-v2", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "requireLiveness": { + "description": "A pointer, so omitting it leaves the current setting alone rather than\nquietly switching liveness off.", + "example": true, + "type": "boolean" + }, + "retentionDays": { + "description": "optional; nil = retain forever", + "example": 90, + "type": "integer" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "internal_delivery_http_handler.updateFaceMetadataRequest": { + "properties": { + "imageUrl": { + "example": "https://storage.example.com/faces/alice_v2.jpg", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.updateKeyRequest": { + "properties": { + "allowedCollectionIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allowedIps": { + "items": { + "type": "string" + }, + "type": "array" + }, + "customRateLimitRpm": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.updateProfileRequest": { + "properties": { + "fullName": { + "example": "Alice Johnson", + "minLength": 2, + "type": "string" + } + }, + "required": [ + "fullName" + ], + "type": "object" + }, + "internal_delivery_http_handler.updateRoleRequest": { + "properties": { + "role": { + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + }, + "internal_delivery_http_handler.updateWebhookRequest": { + "properties": { + "events": { + "example": [ + "face.registered", + "face.deleted" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "isActive": { + "example": true, + "type": "boolean" + }, + "url": { + "example": "https://example.com/webhook", + "type": "string" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.upgradePlanRequest": { + "properties": { + "planType": { + "example": "starter", + "type": "string" + } + }, + "required": [ + "planType" + ], + "type": "object" + }, + "internal_delivery_http_handler.usageNumbers": { + "properties": { + "members": { + "type": "integer" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.usageResponse": { + "properties": { + "currentPeriod": { + "$ref": "#/components/schemas/internal_delivery_http_handler.currentPeriodDTO" + }, + "quota": { + "$ref": "#/components/schemas/internal_delivery_http_handler.quotaDTO" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.userDTO": { + "properties": { + "authority": { + "items": { + "type": "string" + }, + "type": "array" + }, + "avatarUrl": { + "type": "string" + }, + "email": { + "example": "alice@example.com", + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "fullName": { + "example": "Alice Smith", + "type": "string" + }, + "id": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "twoFactorEnabled": { + "type": "boolean" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.verify2FARequest": { + "properties": { + "code": { + "example": "123456", + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, + "internal_delivery_http_handler.verifyEmailRequest": { + "properties": { + "token": { + "example": "abc123...", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "internal_delivery_http_handler.verifyResponse": { + "properties": { + "confidence": { + "example": 0.923, + "type": "number" + }, + "faceId": { + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "match": { + "example": true, + "type": "boolean" + }, + "thresholdUsed": { + "example": 0.4, + "type": "number" + } + }, + "type": "object" + }, + "internal_delivery_http_handler.verifyTwoFactorLoginRequest": { + "properties": { + "code": { + "type": "string" + }, + "preAuthToken": { + "type": "string" + } + }, + "required": [ + "code", + "preAuthToken" + ], + "type": "object" + }, + "internal_delivery_http_handler.webhookDTO": { + "properties": { + "createdAt": { + "type": "string" + }, + "events": { + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "type": "object" + } + }, + "securitySchemes": { + "APIKeyAuth": { + "description": "Enter your API key", + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + }, + "BearerAuth": { + "description": "Enter \"Bearer {token}\" (without quotes)", + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + } + }, + "info": { + "contact": { + "email": "support@livexface.com", + "name": "API Support" + }, + "description": "Multi-Tenant Face Recognition API. Supports user auth, organization management, API key management, face collection CRUD, face registration, 1:1 verification, and 1:N identification using pgvector.", + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "termsOfService": "http://swagger.io/terms/", + "title": "LiveXFace — Face Recognition API as a Service", + "version": "1.0.0" + }, + "openapi": "3.0.3", + "paths": { + "/collections/{collection_id}/active-liveness": { + "post": { + "description": "Judge a sequence of frames (blink, head turn and the passive model). Send the frames in order as frame_0, frame_1, … up to frame_49; at least 5 are required. A passed check returns a single-use livenessToken, valid for 5 minutes and bound to this collection, for enrolling into a collection that requires liveness.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "frame_0": { + "description": "First frame; continue with frame_1 … frame_49 (at least 5 frames)", + "format": "binary", + "type": "string", + "x-formData-name": "frame_0" + } + }, + "required": [ + "frame_0" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/github_com_livexface_backend_internal_domain_entity.ActiveLivenessResult" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IMAGE_REQUIRED: fewer than 5 readable frames" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Active liveness check", + "tags": [ + "Face Recognition" + ] + } + }, + "/collections/{collection_id}/attributes": { + "post": { + "description": "Detects age and gender for all faces in the uploaded image using InsightFace buffalo_l. No face enrollment is performed.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "image": { + "description": "Face image (JPEG/PNG)", + "format": "binary", + "type": "string", + "x-formData-name": "image" + } + }, + "required": [ + "image" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceAttributesResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IMAGE_REQUIRED or INVALID_IMAGE" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "NO_FACE_DETECTED" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Detect face attributes", + "tags": [ + "Faces" + ] + } + }, + "/collections/{collection_id}/batch/{job_id}": { + "get": { + "description": "Returns the current status and per-item results of an async batch registration job.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Batch job ID (UUID)", + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.batchJobDTO" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST: job_id is not a UUID" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BATCH_JOB_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Get batch job status", + "tags": [ + "Faces" + ] + } + }, + "/collections/{collection_id}/export": { + "get": { + "description": "Export all faces in a collection as JSON (metadata only, no embeddings)", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceDTO" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Export collection faces", + "tags": [ + "Faces" + ] + } + }, + "/collections/{collection_id}/faces": { + "get": { + "description": "List all registered faces in a collection. Optionally filter by external_id.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Filter by external_id (exact match)", + "in": "query", + "name": "external_id", + "schema": { + "type": "string" + } + }, + { + "description": "Page size", + "in": "query", + "name": "limit", + "schema": { + "default": 20, + "type": "integer" + } + }, + { + "description": "Page offset", + "in": "query", + "name": "offset", + "schema": { + "default": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceListResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "A page of faces; with external_id, a bare array holding the matching face" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND, or FACE_NOT_FOUND when external_id matches nothing" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "List faces in a collection", + "tags": [ + "Faces" + ] + }, + "post": { + "description": "Upload an image to detect a face, generate a 512-dim embedding, and store it in the collection", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Makes a retry replay the first outcome (1-255 printable ASCII, kept 24 h)", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "external_id": { + "description": "External identifier; defaults to the file name without its extension", + "type": "string", + "x-formData-name": "external_id" + }, + "image": { + "description": "Face image (JPEG/PNG)", + "format": "binary", + "type": "string", + "x-formData-name": "image" + }, + "image_url": { + "description": "Optional image URL for reference", + "type": "string", + "x-formData-name": "image_url" + }, + "liveness_token": { + "description": "Token from a passed active liveness check; required when the collection requires liveness", + "type": "string", + "x-formData-name": "liveness_token" + }, + "metadata": { + "description": "JSON metadata", + "type": "string", + "x-formData-name": "metadata" + } + }, + "required": [ + "image" + ], + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceDTO" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST (invalid metadata or Idempotency-Key), IMAGE_REQUIRED or INVALID_IMAGE or LIVENESS_TOKEN_REQUIRED" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "402": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "FACE_LIMIT_EXCEEDED" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SANDBOX_LIMIT_EXCEEDED, INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "409": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "EXTERNAL_ID_EXISTS or IDEMPOTENCY_KEY_IN_USE" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "NO_FACE_DETECTED, MULTIPLE_FACES, FACE_CONFIDENCE_TOO_LOW, FACE_QUALITY_TOO_LOW, LIVENESS_TOKEN_INVALID, LIVENESS_FACE_MISMATCH or IDEMPOTENCY_KEY_MISMATCH" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Register a face", + "tags": [ + "Faces" + ] + } + }, + "/collections/{collection_id}/faces/batch": { + "delete": { + "description": "Delete multiple faces by their IDs in a single request.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_delivery_http_handler.batchDeleteRequest" + } + } + }, + "description": "Face IDs to delete", + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.batchDeleteResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST: faceIds must hold 1 to 100 IDs" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Batch delete faces", + "tags": [ + "Faces" + ] + }, + "post": { + "description": "Register multiple faces in a single request. Image files via images[0], images[1], etc. Optional entries JSON for externalId, metadata and livenessToken; without an externalId the file name is used.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Makes a retry replay the first outcome (1-255 printable ASCII, kept 24 h)", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "entries": { + "description": "JSON array of {externalId, metadata, livenessToken}, paired with the images by index", + "type": "string", + "x-formData-name": "entries" + }, + "images[0]": { + "description": "First image; continue with images[1] … images[19]", + "format": "binary", + "type": "string", + "x-formData-name": "images[0]" + } + }, + "required": [ + "images[0]" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.batchResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST: 1 to 20 images, valid entries JSON and Idempotency-Key" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "409": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IDEMPOTENCY_KEY_IN_USE" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IDEMPOTENCY_KEY_MISMATCH" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Batch register faces", + "tags": [ + "Faces" + ] + } + }, + "/collections/{collection_id}/faces/batch-async": { + "post": { + "description": "Accepts up to 100 images and registers them asynchronously. Returns a job ID immediately (HTTP 202). Poll GET /collections/{collection_id}/batch/{job_id} for status.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Makes a retry replay the first outcome (1-255 printable ASCII, kept 24 h)", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "entries": { + "description": "JSON array of {externalId, metadata, livenessToken}, paired with the images by index", + "type": "string", + "x-formData-name": "entries" + }, + "images[0]": { + "description": "First image; continue with images[1] … images[99]", + "format": "binary", + "type": "string", + "x-formData-name": "images[0]" + } + }, + "required": [ + "images[0]" + ], + "type": "object" + } + } + } + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.batchJobDTO" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Accepted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST: 1 to 100 readable images, valid entries JSON and Idempotency-Key" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "409": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IDEMPOTENCY_KEY_IN_USE" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IDEMPOTENCY_KEY_MISMATCH" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Submit async batch face registration", + "tags": [ + "Faces" + ] + } + }, + "/collections/{collection_id}/faces/{face_id}": { + "delete": { + "description": "Soft-delete a registered face from the collection", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Face ID (UUID)", + "in": "path", + "name": "face_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND or FACE_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Delete face", + "tags": [ + "Faces" + ] + }, + "get": { + "description": "Get a specific face by its ID", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Face ID (UUID)", + "in": "path", + "name": "face_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceDTO" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND or FACE_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Get face by ID", + "tags": [ + "Faces" + ] + }, + "patch": { + "description": "Update metadata and/or image URL of a registered face", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Face ID (UUID)", + "in": "path", + "name": "face_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_delivery_http_handler.updateFaceMetadataRequest" + } + } + }, + "description": "Updated metadata", + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.faceDTO" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST: metadata must be a JSON object" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND or FACE_NOT_FOUND" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Update face metadata", + "tags": [ + "Faces" + ] + } + }, + "/collections/{collection_id}/identify": { + "post": { + "description": "Search for similar faces in a collection using pgvector cosine similarity. Returns top-K matches above the threshold.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "image": { + "description": "Query face image", + "format": "binary", + "type": "string", + "x-formData-name": "image" + }, + "threshold": { + "default": 0.45, + "description": "Minimum similarity (0-1)", + "type": "number", + "x-formData-name": "threshold" + }, + "top_k": { + "default": 5, + "description": "Max results to return", + "type": "integer", + "x-formData-name": "top_k" + } + }, + "required": [ + "image" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.identifyResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IMAGE_REQUIRED or INVALID_IMAGE" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "NO_FACE_DETECTED or MULTIPLE_FACES" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Identify face (1:N)", + "tags": [ + "Face Recognition" + ] + } + }, + "/collections/{collection_id}/liveness": { + "post": { + "description": "Detect whether the face in the image is live (not a spoof). Uses the ML service's anti-spoofing model.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "image": { + "description": "Face image (JPEG/PNG)", + "format": "binary", + "type": "string", + "x-formData-name": "image" + } + }, + "required": [ + "image" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.livenessResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "IMAGE_REQUIRED or INVALID_IMAGE" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "NO_FACE_DETECTED" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Liveness detection", + "tags": [ + "Face Recognition" + ] + } + }, + "/collections/{collection_id}/verify": { + "post": { + "description": "Compare an uploaded image against a stored face (by face_id) or a second uploaded image (reference_image). Returns match/no-match with confidence score.", + "parameters": [ + { + "description": "Collection ID (UUID)", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "face_id": { + "description": "UUID of stored face to compare against", + "type": "string", + "x-formData-name": "face_id" + }, + "image": { + "description": "Query face image", + "format": "binary", + "type": "string", + "x-formData-name": "image" + }, + "reference_image": { + "description": "Second image to compare against (alternative to face_id)", + "format": "binary", + "type": "string", + "x-formData-name": "reference_image" + }, + "threshold": { + "default": 0.45, + "description": "Similarity threshold (0-1)", + "type": "number", + "x-formData-name": "threshold" + } + }, + "required": [ + "image" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.verifyResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST (neither face_id nor reference_image, or face_id not a UUID), IMAGE_REQUIRED or INVALID_IMAGE" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND or FACE_NOT_FOUND" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "NO_FACE_DETECTED or MULTIPLE_FACES" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Verify face (1:1)", + "tags": [ + "Face Recognition" + ] + } + }, + "/compare": { + "post": { + "description": "Compare two images directly without registering in a collection. Returns similarity score.", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "image1": { + "description": "First face image", + "format": "binary", + "type": "string", + "x-formData-name": "image1" + }, + "image2": { + "description": "Second face image", + "format": "binary", + "type": "string", + "x-formData-name": "image2" + }, + "threshold": { + "default": 0.45, + "description": "Similarity threshold (0-1)", + "type": "number", + "x-formData-name": "threshold" + } + }, + "required": [ + "image1", + "image2" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.verifyResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST: image1 or image2 missing; INVALID_IMAGE" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "NO_FACE_DETECTED or MULTIPLE_FACES" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Compare two faces", + "tags": [ + "Recognition" + ] + } + }, + "/search": { + "post": { + "description": "Perform a 1:N face search spanning multiple (or all) collections within an organization. Provide an optional comma-separated list of collection_ids to restrict the scope; leave empty to search all collections.", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "collection_ids": { + "description": "Comma-separated collection UUIDs to search (empty = all collections)", + "type": "string", + "x-formData-name": "collection_ids" + }, + "image": { + "description": "Query face image", + "format": "binary", + "type": "string", + "x-formData-name": "image" + }, + "threshold": { + "default": 0.45, + "description": "Minimum similarity (0-1)", + "type": "number", + "x-formData-name": "threshold" + }, + "top_k": { + "default": 5, + "description": "Max results to return", + "type": "integer", + "x-formData-name": "top_k" + } + }, + "required": [ + "image" + ], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/internal_delivery_http_handler.crossSearchResponse" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "BAD_REQUEST: a collection_ids entry is not a UUID; IMAGE_REQUIRED or INVALID_IMAGE" + }, + "401": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INVALID_API_KEY" + }, + "403": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "INSUFFICIENT_SCOPE, API_KEY_INACTIVE, API_KEY_EXPIRED, IP_NOT_ALLOWED or COLLECTION_NOT_ALLOWED" + }, + "404": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "COLLECTION_NOT_FOUND" + }, + "422": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "NO_FACE_DETECTED or MULTIPLE_FACES" + }, + "429": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED" + }, + "503": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIResponse" + }, + { + "properties": { + "error": { + "$ref": "#/components/schemas/internal_delivery_http_handler.APIError" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "SERVICE_BUSY: the recognition engine is saturated; retry after Retry-After" + } + }, + "security": [ + { + "APIKeyAuth": [] + } + ], + "summary": "Cross-collection face search", + "tags": [ + "Face Recognition" + ] + } + } + }, + "servers": [ + { + "url": "/api/v1" + } + ] +} diff --git a/contract_test.go b/contract_test.go new file mode 100644 index 0000000..b6ab102 --- /dev/null +++ b/contract_test.go @@ -0,0 +1,348 @@ +package livexface + +import ( + "context" + "encoding/json" + "mime" + "net/http" + "net/http/httptest" + "os" + "reflect" + "regexp" + "sort" + "strings" + "sync" + "testing" +) + +// The contract check: every public SDK method is called against a fake +// server, and each request it makes must use a method and path present in the +// pinned contract (contract/openapi-.json) and send every +// field the contract marks required. + +type contractSchema struct { + Ref string `json:"$ref"` + Required []string `json:"required"` + Properties map[string]json.RawMessage `json:"properties"` +} + +type contractOperation struct { + Parameters []struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + } `json:"parameters"` + RequestBody *struct { + Content map[string]struct { + Schema contractSchema `json:"schema"` + } `json:"content"` + } `json:"requestBody"` +} + +type contractDoc struct { + Info struct { + Version string `json:"version"` + } `json:"info"` + Paths map[string]map[string]contractOperation `json:"paths"` + Components struct { + Schemas map[string]contractSchema `json:"schemas"` + } `json:"components"` +} + +func loadContract(t *testing.T) *contractDoc { + t.Helper() + raw, err := os.ReadFile("contract/openapi-" + ContractVersion + ".json") + if err != nil { + t.Fatalf("read pinned contract: %v", err) + } + var doc contractDoc + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse pinned contract: %v", err) + } + return &doc +} + +func TestContractVersion(t *testing.T) { + raw, err := os.ReadFile("CONTRACT_VERSION") + if err != nil { + t.Fatal(err) + } + if v := strings.TrimSpace(string(raw)); v != ContractVersion { + t.Errorf("CONTRACT_VERSION file = %q, ContractVersion = %q", v, ContractVersion) + } + if v := loadContract(t).Info.Version; v != ContractVersion { + t.Errorf("pinned contract info.version = %q, ContractVersion = %q", v, ContractVersion) + } +} + +// sentRequest is what the fake server saw of one request. +type sentRequest struct { + method string + path string // relative to /api/v1 + mediaType string + fields map[string]bool // multipart field names or JSON body top-level keys + query map[string]bool + header http.Header +} + +// contractCalls calls every public resource method once, with every optional +// argument that adds a field. Keys are ".". +var contractCalls = map[string]func(context.Context, *Client) error{ + "FacesResource.Register": func(ctx context.Context, c *Client) error { + _, err := c.Faces.Register(ctx, "col_1", RegisterInput{ + ExternalID: "emp-1", Image: []byte{0xff, 0xd8}, + Metadata: map[string]interface{}{"dept": "eng"}, LivenessToken: "lvt_1", + }, WithIdempotencyKey("key-1")) + return err + }, + "FacesResource.List": func(ctx context.Context, c *Client) error { + _, _, err := c.Faces.List(ctx, "col_1", ListOptions{Limit: 10, Offset: 5}) + return err + }, + "FacesResource.Get": func(ctx context.Context, c *Client) error { + _, err := c.Faces.Get(ctx, "col_1", "face_1") + return err + }, + "FacesResource.Delete": func(ctx context.Context, c *Client) error { + return c.Faces.Delete(ctx, "col_1", "face_1") + }, + "FacesResource.Verify": func(ctx context.Context, c *Client) error { + _, err := c.Faces.Verify(ctx, "col_1", VerifyInput{Image: []byte{0xff, 0xd8}, FaceID: "face_1", Threshold: 0.5}) + return err + }, + "FacesResource.Identify": func(ctx context.Context, c *Client) error { + _, err := c.Faces.Identify(ctx, "col_1", IdentifyInput{Image: []byte{0xff, 0xd8}, TopK: 3, Threshold: 0.5}) + return err + }, + "FacesResource.Liveness": func(ctx context.Context, c *Client) error { + _, err := c.Faces.Liveness(ctx, "col_1", []byte{0xff, 0xd8}, "") + return err + }, + "FacesResource.ActiveLiveness": func(ctx context.Context, c *Client) error { + _, err := c.Faces.ActiveLiveness(ctx, "col_1", frames(5)) + return err + }, + "FacesResource.Compare": func(ctx context.Context, c *Client) error { + _, err := c.Faces.Compare(ctx, CompareInput{Image1: []byte{0xff, 0xd8}, Image2: []byte{0xff, 0xd8}, Threshold: 0.5}) + return err + }, + "FacesResource.BatchRegister": func(ctx context.Context, c *Client) error { + _, err := c.Faces.BatchRegister(ctx, "col_1", contractBatchItems(), WithIdempotencyKey("key-2")) + return err + }, + "FacesResource.Attributes": func(ctx context.Context, c *Client) error { + _, err := c.Faces.Attributes(ctx, "col_1", []byte{0xff, 0xd8}, "") + return err + }, + "FacesResource.BatchRegisterAsync": func(ctx context.Context, c *Client) error { + _, err := c.Faces.BatchRegisterAsync(ctx, "col_1", contractBatchItems(), WithIdempotencyKey("key-3")) + return err + }, + "FacesResource.GetBatchJob": func(ctx context.Context, c *Client) error { + _, err := c.Faces.GetBatchJob(ctx, "col_1", "job_1") + return err + }, +} + +func contractBatchItems() []BatchItem { + return []BatchItem{ + {ExternalID: "emp-1", Image: []byte{0xff, 0xd8}, Metadata: map[string]interface{}{"dept": "eng"}, LivenessToken: "lvt_1"}, + {ExternalID: "emp-2", Image: []byte{0xff, 0xd8}}, + } +} + +// TestContractCoversAllMethods fails when a public resource method is missing +// from contractCalls, so a new method cannot skip the contract check. +func TestContractCoversAllMethods(t *testing.T) { + ct := reflect.TypeOf(Client{}) + for i := 0; i < ct.NumField(); i++ { + f := ct.Field(i) + if !f.IsExported() || f.Type.Kind() != reflect.Pointer || f.Type.Elem().Kind() != reflect.Struct { + continue + } + for j := 0; j < f.Type.NumMethod(); j++ { + key := f.Type.Elem().Name() + "." + f.Type.Method(j).Name + if contractCalls[key] == nil { + t.Errorf("%s is not called by the contract check; add it to contractCalls", key) + } + } + } +} + +func TestContractRequests(t *testing.T) { + doc := loadContract(t) + + var mu sync.Mutex + var sent []sentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req := sentRequest{ + method: r.Method, + path: strings.TrimPrefix(r.URL.Path, "/api/v1"), + fields: map[string]bool{}, + query: map[string]bool{}, + header: r.Header, + } + for k := range r.URL.Query() { + req.query[k] = true + } + req.mediaType, _, _ = mime.ParseMediaType(r.Header.Get("Content-Type")) + switch req.mediaType { + case "multipart/form-data": + if err := r.ParseMultipartForm(32 << 20); err != nil { + t.Errorf("parse multipart: %v", err) + } else { + for k := range r.MultipartForm.File { + req.fields[k] = true + } + for k := range r.MultipartForm.Value { + req.fields[k] = true + } + } + case "application/json": + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode JSON body: %v", err) + } + for k := range body { + req.fields[k] = true + } + } + mu.Lock() + sent = append(sent, req) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":true,"data":{}}`)) + })) + defer srv.Close() + c := New("lxf_test", WithBaseURL(srv.URL+"/api/v1")) + + names := make([]string, 0, len(contractCalls)) + for name := range contractCalls { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + mu.Lock() + sent = nil + mu.Unlock() + if err := contractCalls[name](context.Background(), c); err != nil { + t.Errorf("%s: %v", name, err) + continue + } + mu.Lock() + reqs := sent + mu.Unlock() + if len(reqs) == 0 { + t.Errorf("%s: made no request", name) + } + for _, req := range reqs { + if tmpl, err := checkContract(doc, req); err != "" { + t.Errorf("%s: %s %s: %s", name, req.method, req.path, err) + } else { + t.Logf("%s -> %s %s", name, req.method, tmpl) + } + } + } +} + +var pathParam = regexp.MustCompile(`\{[^/]+\}`) + +// checkContract returns the contract path template req matched, or a message +// saying how req breaks the contract. +func checkContract(doc *contractDoc, req sentRequest) (string, string) { + // Literal segments beat parameters, as in the server's router, so try + // templates with fewer parameters first. + tmpls := make([]string, 0, len(doc.Paths)) + for tmpl := range doc.Paths { + tmpls = append(tmpls, tmpl) + } + sort.Slice(tmpls, func(i, j int) bool { + ni, nj := strings.Count(tmpls[i], "{"), strings.Count(tmpls[j], "{") + return ni < nj || ni == nj && tmpls[i] < tmpls[j] + }) + var pathFound bool + for _, tmpl := range tmpls { + // Each {param} matches exactly one path segment. + parts := pathParam.Split(tmpl, -1) + for i := range parts { + parts[i] = regexp.QuoteMeta(parts[i]) + } + if !regexp.MustCompile("^" + strings.Join(parts, `[^/]+`) + "$").MatchString(req.path) { + continue + } + pathFound = true + op, ok := doc.Paths[tmpl][strings.ToLower(req.method)] + if !ok { + continue + } + return tmpl, checkRequired(doc, op, req) + } + if pathFound { + return "", "the contract has this path but not this HTTP method" + } + return "", "no path in contract " + ContractVersion + " matches" +} + +func checkRequired(doc *contractDoc, op contractOperation, req sentRequest) string { + var missing []string + for _, p := range op.Parameters { + if !p.Required { + continue + } + switch p.In { + case "query": + if !req.query[p.Name] { + missing = append(missing, "query "+p.Name) + } + case "header": + if req.header.Get(p.Name) == "" { + missing = append(missing, "header "+p.Name) + } + } + } + if op.RequestBody != nil && len(op.RequestBody.Content) > 0 { + body, ok := op.RequestBody.Content[req.mediaType] + if !ok { + return "sends a " + strings.TrimSpace(req.mediaType+" body") + "; the contract accepts none of that media type" + } + schema := body.Schema + if schema.Ref != "" { + schema = doc.Components.Schemas[strings.TrimPrefix(schema.Ref, "#/components/schemas/")] + } + for _, name := range schema.Required { + if !sentField(req.fields, name) { + missing = append(missing, "field "+name) + } + } + } + if len(missing) > 0 { + return "does not send required " + strings.Join(missing, ", ") + } + return "" +} + +var firstOfRepeated = regexp.MustCompile(`^(.*)(\[0\]|_0)$`) + +// sentField reports whether name was sent. The contract documents a repeated +// file field by its first name only: images[0] stands for any images[N], +// frame_0 for any frame_N. +func sentField(fields map[string]bool, name string) bool { + if fields[name] { + return true + } + m := firstOfRepeated.FindStringSubmatch(name) + if m == nil { + return false + } + idx := `\[\d+\]` + if m[2] == "_0" { + idx = `_\d+` + } + re := regexp.MustCompile("^" + regexp.QuoteMeta(m[1]) + idx + "$") + for f := range fields { + if re.MatchString(f) { + return true + } + } + return false +}