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 50ead28..a69e756 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ npm install livexface yarn add livexface ``` +Validated against API contract 1.0.0 (`/openapi.json` `info.version`), exported as `CONTRACT_VERSION`. +`npm test` calls every SDK method and checks its HTTP method, path and required fields against the pinned +`contract/openapi-1.0.0.json`; to move to a new contract, copy the release asset `openapi-.json` into +`contract/`, then update the `CONTRACT_VERSION` file and the constant in `src/index.ts`. + ## Quick Start ```typescript 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/src/index.ts b/src/index.ts index f23ec44..8e80bb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ +/** + * The API contract (`info.version` of `/openapi.json`) this SDK release is + * validated against. Kept equal to the CONTRACT_VERSION file by the tests. + */ +export const CONTRACT_VERSION = '1.0.0' + export { LiveXFace, FacesResource, generateIdempotencyKey } from './client' export { LiveXFaceApiError, LiveXFaceNetworkError } from './error' export type { diff --git a/test/contract.test.js b/test/contract.test.js new file mode 100644 index 0000000..eed05f2 --- /dev/null +++ b/test/contract.test.js @@ -0,0 +1,152 @@ +// Checks every SDK method against the pinned API contract: the HTTP method and +// path must exist in contract/openapi-.json and every field the +// contract marks required must be sent. fetch is stubbed, so no server is needed. +const { test, afterEach } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { LiveXFace, CONTRACT_VERSION } = require('../dist') + +const root = path.join(__dirname, '..') +const pinned = fs.readFileSync(path.join(root, 'CONTRACT_VERSION'), 'utf8').trim() +const contract = JSON.parse(fs.readFileSync(path.join(root, 'contract', `openapi-${pinned}.json`), 'utf8')) +const prefix = contract.servers[0].url // "/api/v1"; contract paths are relative to it + +const realFetch = globalThis.fetch +afterEach(() => { + globalThis.fetch = realFetch +}) + +const client = new LiveXFace({ apiKey: 'lxf_test_x', baseUrl: `http://api.test${prefix}` }) +const img = () => Buffer.from([0xff, 0xd8, 0xff]) +const col = 'col_1' +const items = [ + { externalId: 'u1', image: img(), metadata: { team: 'a' }, livenessToken: 'lvt_1' }, + { externalId: 'u2', image: img() }, +] + +// One call per public method, keyed by the client property holding the +// resource. Optional arguments are passed so every field the SDK can send is sent. +const cases = { + faces: { + register: (f) => + f.register(col, { externalId: 'u1', image: img(), metadata: { team: 'a' }, livenessToken: 'lvt_1', idempotencyKey: 'k1' }), + list: (f) => f.list(col, { limit: 10, offset: 5 }), + get: (f) => f.get(col, 'face_1'), + getByExternalId: (f) => f.getByExternalId(col, 'u1'), + delete: (f) => f.delete(col, 'face_1'), + verify: (f) => f.verify(col, { image: img(), faceId: 'face_1', threshold: 0.5 }), + identify: (f) => f.identify(col, { image: img(), topK: 3, threshold: 0.5 }), + liveness: (f) => f.liveness(col, { image: img() }), + activeLiveness: (f) => f.activeLiveness(col, Array.from({ length: 5 }, img)), + compare: (f) => f.compare({ image1: img(), image2: img(), threshold: 0.5 }), + batchRegister: (f) => f.batchRegister(col, items, { idempotencyKey: 'k2' }), + batchDelete: (f) => f.batchDelete(col, ['face_1', 'face_2']), + attributes: (f) => f.attributes(col, { image: img(), filename: 'a.png' }), + batchRegisterAsync: (f) => f.batchRegisterAsync(col, items, { idempotencyKey: 'k3' }), + getBatchJob: (f) => f.getBatchJob(col, 'job_1'), + }, +} + +// The client itself plus every resource object it exposes (client.faces, ...). +const surfaces = { + client, + ...Object.fromEntries( + Object.entries(client).filter( + ([k, v]) => !k.startsWith('_') && v && typeof v === 'object' && Object.getPrototypeOf(v) !== Object.prototype, + ), + ), +} +const publicMethods = (obj) => + Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).filter( + (n) => n !== 'constructor' && !n.startsWith('_') && typeof obj[n] === 'function', + ) + +// `images[0]` stands for any `images[N]`, `frame_0` for any `frame_N`. +const slot = (name) => name.replace(/\[\d+\]$/, '[]').replace(/_\d+$/, '_') +const paramCount = (template) => (template.match(/\{/g) ?? []).length + +/** The contract path template for `p`; a literal segment beats a parameter, as in the router. */ +function findTemplate(p) { + const segs = p.split('/') + return Object.keys(contract.paths) + .filter((t) => { + const ts = t.split('/') + return ts.length === segs.length && ts.every((s, i) => s.startsWith('{') || s === segs[i]) + }) + .sort((a, b) => paramCount(a) - paramCount(b))[0] +} + +function requiredFields(op, contentType) { + const params = (op.parameters ?? []) + .filter((p) => p.required && p.in !== 'path') // path params are satisfied by the match + .map((p) => `${p.in}:${p.in === 'header' ? p.name.toLowerCase() : p.name}`) + let schema = op.requestBody?.content?.[contentType]?.schema ?? {} + if (schema.$ref) schema = contract.components.schemas[schema.$ref.split('/').pop()] + return [...params, ...(schema.required ?? []).map((n) => `body:${slot(n)}`)] +} + +/** Calls `run` with fetch stubbed and returns the one request it made. */ +async function capture(run) { + const calls = [] + globalThis.fetch = async (url, init) => { + calls.push({ url, init }) + // A one-face array satisfies getByExternalId; every other method returns data as-is. + return new Response(JSON.stringify({ success: true, data: [{ id: 'face_1' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + await run() + assert.equal(calls.length, 1, 'expected exactly one request') + return calls[0] +} + +test(`CONTRACT_VERSION matches the CONTRACT_VERSION file and the pinned contract (${pinned})`, () => { + assert.equal(CONTRACT_VERSION, pinned) + assert.equal(contract.info.version, pinned) +}) + +test('every public SDK method is exercised by the contract check', () => { + for (const [name, obj] of Object.entries(surfaces)) { + const missing = publicMethods(obj).filter((m) => !(cases[name] && m in cases[name])) + assert.deepEqual(missing, [], `${name} has public methods the contract check does not call; add them to cases`) + } +}) + +for (const [resource, methods] of Object.entries(cases)) { + for (const [method, run] of Object.entries(methods)) { + const name = `${resource}.${method}` + test(`${name} matches contract ${pinned}`, async () => { + const { url, init } = await capture(() => run(surfaces[resource])) + const u = new URL(url) + assert.ok(u.pathname.startsWith(prefix), `${name}: ${u.pathname} is outside ${prefix}`) + const p = u.pathname.slice(prefix.length) + const verb = init.method.toUpperCase() + + const template = findTemplate(p) + assert.ok(template, `${name}: ${verb} ${p} matches no path in contract ${pinned}`) + const op = contract.paths[template][verb.toLowerCase()] + assert.ok(op, `${name}: contract ${pinned} has no ${verb} on ${template}`) + + const sent = new Set([...u.searchParams.keys()].map((k) => `query:${k}`)) + for (const h of Object.keys(init.headers ?? {})) sent.add(`header:${h.toLowerCase()}`) + let contentType + if (init.body instanceof FormData) { + contentType = 'multipart/form-data' + for (const k of init.body.keys()) sent.add(`body:${slot(k)}`) + } else if (typeof init.body === 'string') { + contentType = 'application/json' + for (const k of Object.keys(JSON.parse(init.body))) sent.add(`body:${k}`) + } + if (contentType) { + assert.ok(op.requestBody?.content?.[contentType], `${name}: ${verb} ${template} does not accept ${contentType}`) + } else { + assert.ok(!op.requestBody?.required, `${name}: ${verb} ${template} requires a body; none sent`) + } + + const missing = requiredFields(op, contentType).filter((f) => !sent.has(f)) + assert.deepEqual(missing, [], `${name}: ${verb} ${template} is missing required fields`) + }) + } +}