From f368a82d9ce6ee043b56eb2e4eeb60c14ff9e4e3 Mon Sep 17 00:00:00 2001 From: Imron Reviady Date: Sat, 3 Oct 2026 19:38:36 +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 + contract/openapi-1.0.0.json | 5341 +++++++++++++++++++++++++++++++++++ livexface/__init__.py | 3 + tests/test_contract.py | 218 ++ 5 files changed, 5565 insertions(+) create mode 100644 CONTRACT_VERSION create mode 100644 contract/openapi-1.0.0.json create mode 100644 tests/test_contract.py 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 53784c2..b123b04 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Official Python SDK for [LiveXFace](https://github.com/livexface/livexface-pytho pip install livexface ``` +Validated against API contract 1.0.0 (`/openapi.json` `info.version`). The test suite calls every client method against the contract committed in `contract/` and fails if a method, path or required field is not in it; to move to a new contract, copy the release asset `openapi-.json` into `contract/`, then update `CONTRACT_VERSION` and `livexface.CONTRACT_VERSION`. + ## Quick Start ```python 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/livexface/__init__.py b/livexface/__init__.py index c6462ac..7869e49 100644 --- a/livexface/__init__.py +++ b/livexface/__init__.py @@ -19,7 +19,10 @@ ) __version__ = "0.1.0" +# The API contract (`info.version` of /openapi.json) this release is validated against. +CONTRACT_VERSION = "1.0.0" __all__ = [ + "CONTRACT_VERSION", "LiveXFace", "new_idempotency_key", "LiveXFaceApiError", diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..a2c9b3e --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,218 @@ +"""Every client method against the pinned API contract: its HTTP method and +path must be in contract/openapi-.json, and it must send the +fields the contract marks required. Requests go to a local HTTP server so the +wire format (multipart field names, JSON keys, query) is what gets checked.""" + +from __future__ import annotations + +import inspect +import json +import re +import threading +from collections.abc import Iterator +from email.parser import BytesParser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +import pytest + +import livexface +from livexface import LiveXFace + +ROOT = Path(__file__).resolve().parents[1] +VERSION = (ROOT / "CONTRACT_VERSION").read_text().strip() +CONTRACT: dict[str, Any] = json.loads((ROOT / "contract" / f"openapi-{VERSION}.json").read_text()) +PREFIX = CONTRACT["servers"][0]["url"] # contract paths are relative to it + +FACE = {"id": "f1", "collectionId": "c1", "externalId": "u1", "createdAt": "2026-09-28T00:00:00Z"} +# One reply that every result type can parse. +DATA = {**FACE, "match": True, "confidence": 0.9, "thresholdUsed": 0.5, "succeeded": 1, "failed": 0, "status": "queued"} +IMG = b"img" +ITEMS = [ + {"external_id": "u1", "image": IMG, "metadata": {"k": "v"}, "liveness_token": "lt"}, + {"external_id": "u2", "image": IMG}, +] +NO_CONTENT = None # the server answers 204 + +# Every public client method: (args, kwargs, reply data). Optional arguments +# that add fields are passed so those fields are checked too. +CALLS: dict[str, tuple[tuple[Any, ...], dict[str, Any], Any]] = { + "faces.register": ( + ("c1", IMG, "u1"), + {"metadata": {"k": "v"}, "liveness_token": "lt", "idempotency_key": "key-1"}, + DATA, + ), + "faces.list": (("c1",), {"limit": 5, "offset": 10}, {"faces": [FACE], "total": 1}), + "faces.get": (("c1", "f1"), {}, DATA), + "faces.get_by_external_id": (("c1", "u1"), {}, [FACE]), + "faces.delete": (("c1", "f1"), {}, NO_CONTENT), + "faces.verify": (("c1", IMG, "f1"), {"threshold": 0.5}, DATA), + "faces.identify": (("c1", IMG), {"top_k": 3, "threshold": 0.5}, DATA), + "faces.liveness": (("c1", IMG), {}, DATA), + "faces.active_liveness": (("c1", [IMG] * 5), {}, DATA), + "faces.compare": ((IMG, IMG), {"threshold": 0.5}, DATA), + "faces.batch_register": (("c1", ITEMS), {"idempotency_key": "key-2"}, DATA), + "faces.batch_delete": (("c1", ["f1", "f2"]), {}, DATA), + "faces.attributes": (("c1", IMG), {}, DATA), + "faces.batch_register_async": (("c1", ITEMS), {"idempotency_key": "key-3"}, DATA), + "faces.get_batch_job": (("c1", "j1"), {}, DATA), +} + + +class Captured: + def __init__(self, method: str, path: str, content_type: str, fields: set[str], headers: set[str]) -> None: + self.method = method + self.path = path + self.content_type = content_type + self.fields = fields + self.headers = headers + + +def _body_fields(full_type: str, body: bytes) -> set[str]: + """Multipart field names or top-level JSON keys of a request body.""" + content_type = full_type.split(";")[0].strip() + if content_type == "multipart/form-data": + # The multipart parser needs the boundary from the Content-Type header. + msg = BytesParser().parsebytes(b"Content-Type: " + full_type.encode() + b"\r\n\r\n" + body) + names = (part.get_param("name", header="content-disposition") for part in msg.walk()) + return {str(name) for name in names if name} + if content_type == "application/json": + data = json.loads(body) + return set(data) if isinstance(data, dict) else set() + return set() + + +class FakeAPI: + """Records each request and answers with ``reply`` (204 when it is None).""" + + def __init__(self) -> None: + self.reply: Any = None + self.requests: list[Captured] = [] + api = self + + class Handler(BaseHTTPRequestHandler): + def _handle(self) -> None: + body = self.rfile.read(int(self.headers.get("Content-Length") or 0)) + full_type = self.headers.get("Content-Type") or "" + content_type = full_type.split(";")[0].strip() + url = urlsplit(self.path) + fields = set(parse_qs(url.query, keep_blank_values=True)) | _body_fields(full_type, body) + api.requests.append( + Captured(self.command, url.path, content_type, fields, {k.lower() for k in self.headers}) + ) + if api.reply is NO_CONTENT: + self.send_response(204) + self.end_headers() + return + raw = json.dumps({"success": True, "data": api.reply}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _handle + + def log_message(self, *args: Any) -> None: + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}{PREFIX}" + + +@pytest.fixture(scope="module") +def api() -> Iterator[FakeAPI]: + fake = FakeAPI() + t = threading.Thread(target=fake.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + t.start() + yield fake + fake.server.shutdown() + fake.server.server_close() + + +def _template_regex(template: str) -> str: + return "/".join("[^/]+" if seg.startswith("{") else re.escape(seg) for seg in template.split("/")) + + +def _schema(schema: dict[str, Any]) -> dict[str, Any]: + ref = schema.get("$ref") + if ref: + node: Any = CONTRACT + for part in ref.lstrip("#/").split("/"): + node = node[part] + return dict(node) + return schema + + +def _normalize(field: str) -> str: + # The contract documents repeated file fields by their first name only. + field = re.sub(r"\[\d+\]$", "[0]", field) + return re.sub(r"^frame_\d+$", "frame_0", field) + + +def _check(req: Captured) -> None: + where = f"{req.method} {req.path}" + assert req.path.startswith(PREFIX + "/"), f"{where}: not under the contract's server URL {PREFIX}" + path = req.path[len(PREFIX):] + method = req.method.lower() + # A literal segment beats a {param} one, as in the API's router. + candidates = sorted( + (t for t, ops in CONTRACT["paths"].items() if method in ops and re.fullmatch(_template_regex(t), path)), + key=lambda t: t.count("{"), + ) + assert candidates, f"{where}: no {req.method} operation in contract {VERSION} matches path {path}" + template = candidates[0] + op = CONTRACT["paths"][template][method] + + required: set[str] = set() + for param in op.get("parameters", []): + param = _schema(param) + if param.get("required") and param["in"] == "query": + required.add(param["name"]) + if param.get("required") and param["in"] == "header": + assert param["name"].lower() in req.headers, f"{where}: required header {param['name']} not sent" + body = op.get("requestBody") + if body and (body.get("required") or req.content_type): + content = body["content"] + assert req.content_type in content, ( + f"{where}: sent {req.content_type or 'no body'}, contract {VERSION} {template} takes {sorted(content)}" + ) + required |= set(_schema(content[req.content_type]["schema"]).get("required", [])) + + sent = {_normalize(f) for f in req.fields} + missing = sorted(required - sent) + assert not missing, f"{where}: contract {VERSION} {req.method} {template} requires {missing}, sent {sorted(sent)}" + + +def test_contract_version_matches_pinned_contract() -> None: + assert livexface.CONTRACT_VERSION == VERSION == CONTRACT["info"]["version"] + + +@pytest.mark.parametrize("name", sorted(CALLS)) +def test_method_is_in_contract(api: FakeAPI, name: str) -> None: + args, kwargs, reply = CALLS[name] + resource, method = name.split(".") + client = LiveXFace(api_key="lxf_test", base_url=api.base_url) + api.reply = reply + api.requests.clear() + + getattr(getattr(client, resource), method)(*args, **kwargs) + + assert len(api.requests) == 1, f"{name} made {len(api.requests)} requests" + _check(api.requests[0]) + + +def test_every_public_method_is_checked() -> None: + client = LiveXFace(api_key="lxf_test") + public = { + f"{attr}.{name}" + for attr, resource in vars(client).items() + if type(resource).__module__ == "livexface.client" + for name, _ in inspect.getmembers(type(resource), inspect.isfunction) + if not name.startswith("_") + } + assert public, "found no resource classes on the client" + assert not public - set(CALLS), f"public methods missing from the contract check: {sorted(public - set(CALLS))}" + assert not set(CALLS) - public, f"CALLS names methods that no longer exist: {sorted(set(CALLS) - public)}"