diff --git a/backend/api_v2/api_deployment_views.py b/backend/api_v2/api_deployment_views.py index d5cfa800a1..4dce5967b2 100644 --- a/backend/api_v2/api_deployment_views.py +++ b/backend/api_v2/api_deployment_views.py @@ -5,6 +5,13 @@ from django.db.models import F, OuterRef, QuerySet, Subquery from django.http import HttpResponse +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_serializer, + extend_schema_view, +) from permissions.membership_views import OwnerManagementMixin from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg from permissions.resource_share_views import ResourceShareManagementMixin @@ -37,6 +44,7 @@ from api_v2.serializers import ( APIDeploymentListSerializer, APIDeploymentSerializer, + APIExecutionResponseSerializer, DeploymentResponseSerializer, ExecutionQuerySerializer, ExecutionRequestSerializer, @@ -50,6 +58,149 @@ logger = logging.getLogger(__name__) +# Declares no field of its own, so every backend parameter arrives free and a +# change to the real serializer moves the spec. It exists only to carry a +# caller-facing description in place of the implementation docstring, and to +# keep the published model name stable. +@extend_schema_serializer(component_name="ExecuteRequest") +class ExecuteRequest(ExecutionRequestSerializer): + """The documents to run, and the options that shape the result. + + Supply `files`, `presigned_urls`, or both. + """ + + +class FileResult(serializers.Serializer): + file = serializers.CharField() + file_execution_id = serializers.CharField(required=False) + status = serializers.CharField(required=False) + result = serializers.JSONField(required=False) + metadata = serializers.JSONField(required=False) + metrics = serializers.JSONField(required=False) + error = serializers.CharField(required=False, allow_null=True) + + +# Subclasses the serializer that builds the response, so a field added or +# removed there moves the spec. Docstrings on these annotation serializers are +# published as the client-facing model description, so they are written for +# the caller rather than the maintainer. +class ExecutionMessage(APIExecutionResponseSerializer): + """The execution's identity and, once it has finished, its per-file + results. + """ + + # The one field that has to be restated: the real declaration is an + # untyped JSONField, which gives generated clients nothing to work with. + # The backend also sends `result: null` while pending, and without + # allow_null the generated deserialiser iterates None and crashes. + result = FileResult(many=True, required=False, allow_null=True) + + +class ExecuteResponse(serializers.Serializer): + message = ExecutionMessage() + + +class StatusResponse(serializers.Serializer): + status = serializers.CharField() + message = FileResult(many=True, required=False, allow_null=True) + + +class ErrorResponse(serializers.Serializer): + status = serializers.CharField(required=False) + message = serializers.JSONField(required=False, allow_null=True) + + +# The pattern the route itself enforces, restated so a generated client can +# reject a mistyped identifier without a round trip. +PATH_SEGMENT = {"type": "string", "pattern": r"^[\w-]+$"} + +DEPLOYMENT_PATH_PARAMETERS = [ + OpenApiParameter( + "org_name", + PATH_SEGMENT, + OpenApiParameter.PATH, + description="Organization identifier.", + ), + OpenApiParameter( + "api_name", + PATH_SEGMENT, + OpenApiParameter.PATH, + description="API deployment name.", + ), +] + + +DEPLOYMENT_AUTH = [{"deploymentKey": []}] + +# Every failure a caller has to handle. Declared explicitly because a client +# generated without them treats an authentication or rate-limit response as an +# unknown status and has nothing to branch on. +DEPLOYMENT_ERRORS = { + 400: OpenApiResponse(ErrorResponse, description="The request failed validation."), + 401: OpenApiResponse(ErrorResponse, description="The API key is not valid."), + 403: OpenApiResponse(ErrorResponse, description="No API key was supplied."), + 404: OpenApiResponse(ErrorResponse, description="No such active deployment."), + 429: OpenApiResponse( + ErrorResponse, description="Too many concurrent executions; retry later." + ), + 500: ErrorResponse, +} + +EXECUTE_DESCRIPTION = ( + "Execute an API deployment against one or more documents.\n\n" + "Supply the documents either as `files` (multipart upload) or as " + "`presigned_urls` (HTTPS S3 URLs), or both — a request carrying neither is " + f"rejected, and the two together may not exceed " + f"{ExecutionRequestSerializer.MAX_FILES_ALLOWED} documents.\n\n" + "With the default `timeout` of -1 the call returns as soon as the " + "execution is queued; read the outcome from the status endpoint." +) + +STATUS_DESCRIPTION = ( + "Read the result of a previously started execution.\n\n" + "This read is one-shot: the first call that observes a completed execution " + "acknowledges it and the stored result is discarded, so every later call " + "for that execution answers 406. Poll while the execution is pending, and " + "keep the payload of the call that returns it — it cannot be fetched again." +) + + +# The generated clients take their command names, module paths and request +# shapes from here, so this block is part of the public API surface. +@extend_schema_view( + post=extend_schema( + operation_id="execute", + tags=["deployment"], + auth=DEPLOYMENT_AUTH, + parameters=DEPLOYMENT_PATH_PARAMETERS, + request={"multipart/form-data": ExecuteRequest}, + responses={ + 200: ExecuteResponse, + 409: OpenApiResponse( + ErrorResponse, description="The deployment has no active API key." + ), + 422: ExecuteResponse, + **DEPLOYMENT_ERRORS, + }, + description=EXECUTE_DESCRIPTION, + ), + get=extend_schema( + operation_id="status", + tags=["deployment"], + auth=DEPLOYMENT_AUTH, + parameters=DEPLOYMENT_PATH_PARAMETERS + [ExecutionQuerySerializer], + responses={ + 200: StatusResponse, + 406: OpenApiResponse( + ErrorResponse, + description="The result was already consumed by an earlier call.", + ), + 422: StatusResponse, + **DEPLOYMENT_ERRORS, + }, + description=STATUS_DESCRIPTION, + ), +) class DeploymentExecution(views.APIView): def initialize_request(self, request: Request, *args: Any, **kwargs: Any) -> Request: """To remove csrf request for public API. diff --git a/backend/api_v2/deployment_spec_urls.py b/backend/api_v2/deployment_spec_urls.py new file mode 100644 index 0000000000..0aa2d998bd --- /dev/null +++ b/backend/api_v2/deployment_spec_urls.py @@ -0,0 +1,27 @@ +"""URLconf used only to generate the API deployment OpenAPI spec. + +``api_v2.execution_urls`` is an included sub-urlconf, so generating against it +directly yields paths without the prefix it is mounted at — a spec describing +URLs the server does not serve. The mount is selected out of the served +urlconf rather than restated here, so a change to where the deployment API is +mounted moves the generated paths with it. +""" + +from django.core.exceptions import ImproperlyConfigured + +from backend import base_urls + +DEPLOYMENT_URLCONF = "api_v2.execution_urls" + +urlpatterns = [ + entry + for entry in base_urls.urlpatterns + if getattr(getattr(entry, "urlconf_name", None), "__name__", None) + == DEPLOYMENT_URLCONF +] + +if not urlpatterns: + raise ImproperlyConfigured( + f"{DEPLOYMENT_URLCONF} is not mounted in backend.base_urls; the API " + "deployment spec would be generated for no routes at all." + ) diff --git a/backend/api_v2/management/commands/generate_docstudio_spec.py b/backend/api_v2/management/commands/generate_docstudio_spec.py new file mode 100644 index 0000000000..a091073a39 --- /dev/null +++ b/backend/api_v2/management/commands/generate_docstudio_spec.py @@ -0,0 +1,97 @@ +"""Regenerate the committed API deployment OpenAPI spec. + +The spec is the contract the published clients and their generated SDKs are +built from, so it is committed and CI fails on drift: change a route, a +serializer or the schema annotation, and regenerate in the same PR. + + uv run python manage.py generate_docstudio_spec # from backend/ + uv run python manage.py generate_docstudio_spec --check # no write, drift is an error + +The generated paths carry ``API_DEPLOYMENT_PATH_PREFIX``, so regenerate in an +environment that does not override it — the committed artifact describes the +deployment as it is served publicly, not as one installation mounts it. +""" + +import json +from pathlib import Path +from typing import Any + +from django.core.management.base import BaseCommand, CommandError +from drf_spectacular.drainage import GENERATOR_STATS +from drf_spectacular.generators import SchemaGenerator + +DEFAULT_OUT = Path(__file__).resolve().parents[4] / "specs" / "docstudio-oss.json" +URLCONF = "api_v2.deployment_spec_urls" +REGENERATE = "uv run python manage.py generate_docstudio_spec" + + +class SpecGenerationFailed(CommandError): + """Raised when the generator had to guess.""" + + +def render_spec() -> str: + """The committed artifact, byte for byte. + + Shared with the drift test: two copies of this could disagree, and then + the gate rejects exactly the file the command it names produces. + """ + GENERATOR_STATS.reset() + schema = SchemaGenerator(urlconf=URLCONF).get_schema(request=None, public=True) + if GENERATOR_STATS: + # spectacular downgrades "unable to guess serializer" to a warning and + # writes a plausible, wrong operation. Nothing downstream can tell that + # apart from an annotation that is simply thin. + diagnostics = "\n".join( + f" {severity}: {message}" + for severity, cache in ( + ("error", GENERATOR_STATS._error_cache), + ("warning", GENERATOR_STATS._warn_cache), + ) + for message in cache + ) + raise SpecGenerationFailed( + f"The generator reported problems, so the spec would describe an " + f"API nobody implements:\n{diagnostics}" + ) + # Sorted keys are what make the committed artifact a usable drift signal. + return json.dumps(schema, indent=2, sort_keys=True) + "\n" + + +class Command(BaseCommand): + help = "Generate the API deployment OpenAPI spec." + + def add_arguments(self, parser: Any) -> None: + parser.add_argument("--out", type=Path, default=DEFAULT_OUT) + parser.add_argument( + "--check", + action="store_true", + help="Fail if the file on disk differs, instead of writing it.", + ) + + def handle(self, *args: Any, **options: Any) -> None: + rendered = render_spec() + + out: Path = options["out"] + if options["check"]: + current = out.read_text() if out.exists() else "" + if current != rendered: + raise CommandError( + f"{out} is out of date. Run `{REGENERATE}` from `backend/` " + "and commit the result." + ) + self.stdout.write(f"{out} is up to date") + return + + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(rendered) + schema = json.loads(rendered) + operations = sum( + 1 + for methods in schema["paths"].values() + for method in methods + if method in {"get", "post", "put", "patch", "delete"} + ) + self.stdout.write( + f"{out}: {len(schema['paths'])} paths, {operations} operations, " + f"{len(schema.get('components', {}).get('schemas', {}))} schemas" + ) diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 3db7f53db6..e376cd401b 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -6,6 +6,8 @@ from django.apps import apps from django.core.validators import RegexValidator +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from pipeline_v2.models import Pipeline from prompt_studio.prompt_profile_manager_v2.models import ProfileManager from rest_framework import serializers @@ -218,6 +220,13 @@ def to_representation(self, instance: APIKey) -> OrderedDict[str, Any]: return representation +@extend_schema_field(OpenApiTypes.BINARY) +class UploadField(FileField): + """A bare ``FileField`` maps to ``format: uri`` -- correct on output, wrong + for a multipart upload, and generators emit ``str`` for it. + """ + + class ExecutionRequestSerializer(TagParamsSerializer): """Execution request serializer. @@ -320,7 +329,7 @@ def validate_custom_data(self, value): return value files = ListField( - child=FileField(), + child=UploadField(), required=False, allow_empty=True, ) diff --git a/backend/api_v2/tests/test_docstudio_spec.py b/backend/api_v2/tests/test_docstudio_spec.py new file mode 100644 index 0000000000..50b0e99cad --- /dev/null +++ b/backend/api_v2/tests/test_docstudio_spec.py @@ -0,0 +1,114 @@ +"""The committed spec is the contract the published clients are generated from. + +A route, serializer or schema-annotation change that is not regenerated ships a +spec describing an API the server no longer serves, so drift fails here rather +than in a client repo. +""" + +import json + +from django.urls import resolve, reverse +from drf_spectacular.drainage import GENERATOR_STATS + +from api_v2.management.commands.generate_docstudio_spec import ( + DEFAULT_OUT, + REGENERATE, + render_spec, +) + + +#: Keys under a path item that are operations. The rest -- `parameters`, +#: `summary`, vendor extensions -- describe the path, not a call. +_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace") + + +def _committed() -> dict: + return json.loads(DEFAULT_OUT.read_text()) + + +def _operations(spec: dict) -> list[tuple[str, str, dict]]: + """Every (path, method, operation) the spec documents. + + The spec grows an endpoint at a time, and a check written against exactly + one of them fails on the next addition without anything being wrong. + """ + return [ + (path, method, operation) + for path, path_item in spec["paths"].items() + for method, operation in path_item.items() + if method in _METHODS + ] + + +def test_committed_spec_matches_the_code() -> None: + assert DEFAULT_OUT.exists(), f"{DEFAULT_OUT} is missing" + assert DEFAULT_OUT.read_text() == render_spec(), ( + f"{DEFAULT_OUT} is out of date. Run `{REGENERATE}` from `backend/` and " + "commit the result." + ) + + +def test_generation_reports_no_diagnostics() -> None: + """A warned-about operation is published with guessed request and response + shapes, and the drift comparison certifies the guess.""" + render_spec() + assert not GENERATOR_STATS._error_cache + assert not GENERATOR_STATS._warn_cache + + +def test_spec_paths_are_the_urls_the_server_serves() -> None: + """Resolves the real mount rather than restating it: a spec generated for + URLs the server does not serve is the failure this file exists to catch.""" + served = reverse( + "api_deployment_execution", kwargs={"org_name": "ORG", "api_name": "API"} + ) + documented = [ + path.replace("{org_name}", "ORG").replace("{api_name}", "API") + for path in _committed()["paths"] + ] + + assert served.rstrip("/") in [path.rstrip("/") for path in documented] + for path in documented: + # Raises Resolver404 if the spec documents a URL nothing answers. + resolve(path if path.endswith("/") else f"{path}/") + + +def test_spec_documents_the_deployment_operations() -> None: + spec = _committed() + documented = {operation["operationId"] for _, _, operation in _operations(spec)} + + assert {"execute", "status"} <= documented + assert "deployment" in [tag["name"] for tag in spec["tags"]] + + +def test_operations_require_the_deployment_key() -> None: + """Without this the unset DRF authentication default is published as + though it were a decision, and no generated client can authenticate.""" + spec = _committed() + scheme = spec["components"]["securitySchemes"]["deploymentKey"] + + assert (scheme["type"], scheme["scheme"]) == ("http", "bearer") + for path, method, operation in _operations(spec): + assert operation["security"] == [{"deploymentKey": []}], f"{method} {path}" + + +def test_clients_can_branch_on_every_failure_they_will_see() -> None: + for path, method, operation in _operations(_committed()): + assert {"400", "401", "403", "404", "429", "500"} <= set( + operation["responses"] + ), f"{method} {path}" + + +def test_the_one_shot_read_is_documented_where_a_client_will_see_it() -> None: + """The semantics that a status read destroys the result must reach the + generated client, not live in a source comment.""" + reads = [ + operation + for _, _, operation in _operations(_committed()) + if operation["operationId"] == "status" + ] + + assert reads + for status_op in reads: + assert "one-shot" in status_op["description"] + assert status_op["responses"]["406"]["description"].strip() diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py index d14f87b304..cf21bd3e1e 100644 --- a/backend/backend/settings/base.py +++ b/backend/backend/settings/base.py @@ -653,6 +653,41 @@ def filter(self, record): "DEFAULT_VERSION": "v1", "ALLOWED_VERSIONS": ["v1"], "VERSION_PARAM": "version", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +# Read while generating the API deployment OpenAPI spec +# (``manage.py generate_docstudio_spec``). ``DEFAULT_SCHEMA_CLASS`` above is a +# project-wide DRF default, but DRF dereferences it only when a schema is +# generated, so neither has an effect at request time. +SPECTACULAR_SETTINGS = { + "TITLE": "Unstract API", + "VERSION": "v1", + "PREPROCESSING_HOOKS": ["drf_spectacular.hooks.preprocess_exclude_path_format"], + "SERVE_INCLUDE_SCHEMA": False, + # DRF's unset ``DEFAULT_AUTHENTICATION_CLASSES`` would otherwise be + # introspected as a decision and publish session and basic auth, which + # these endpoints do not accept. + "APPEND_COMPONENTS": { + "securitySchemes": { + "deploymentKey": { + "type": "http", + "scheme": "bearer", + "description": "The API deployment's own key.", + } + } + }, + # Group descriptions clients show in their help; without this the spec has + # no root `tags` array and the text has nowhere to live. + "TAGS": [ + { + "name": "deployment", + "description": ( + "Run an API deployment against one or more documents and poll " + "the result." + ), + } + ], } # These paths will work without authentication diff --git a/backend/mcp_server/views.py b/backend/mcp_server/views.py index 53ef1f4781..7c85f5d237 100644 --- a/backend/mcp_server/views.py +++ b/backend/mcp_server/views.py @@ -14,6 +14,7 @@ from typing import Any from api_v2.deployment_helper import DeploymentHelper +from drf_spectacular.utils import extend_schema from rest_framework.request import Request from mcp_server.context import MCPContext @@ -23,6 +24,10 @@ logger = logging.getLogger(__name__) +# MCP speaks JSON-RPC over one POST, so it has no REST surface worth +# describing; leaving it in would publish guessed request and response shapes +# to every client generated from the spec. +@extend_schema(exclude=True) class MCPServerView(BaseMCPView): """MCP JSON-RPC endpoint for a single API deployment. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7eee250b9b..1cd74ecdcb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ "django-redis==5.4.0", "django-tenants==3.5.0", "drf-standardized-errors>=0.12.6", + # Pinned: its rendering is the committed spec, so an upgrade rewrites the + # contract published clients are generated from. + "drf-spectacular==0.30.0", "drf-yasg>=1.21.8", # For API docs "psycopg2-binary==2.9.9", "python-dotenv==1.2.2", diff --git a/backend/uv.lock b/backend/uv.lock index c80f0ecea6..52bd298262 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -900,6 +900,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/be/3032490fa33b36ddc8c4b1da3252c6f974e7133f1a50de00c6b85cca203a/docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9", size = 148096, upload-time = "2023-06-01T14:24:47.769Z" }, ] +[[package]] +name = "drf-spectacular" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "inflection" }, + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/43/41d25039a6a53545420ebc98eb9f877ec9fe30c7bd03fefabcaf9b953af7/drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9", size = 252311, upload-time = "2026-07-06T11:29:46.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150, upload-time = "2026-07-06T11:29:45.12Z" }, +] + [[package]] name = "drf-standardized-errors" version = "0.15.0" @@ -3688,6 +3705,7 @@ dependencies = [ { name = "django-redis" }, { name = "django-tenants" }, { name = "djangorestframework" }, + { name = "drf-spectacular" }, { name = "drf-standardized-errors" }, { name = "drf-yasg" }, { name = "google-cloud-recaptcha-enterprise" }, @@ -3755,6 +3773,7 @@ requires-dist = [ { name = "django-redis", specifier = "==5.4.0" }, { name = "django-tenants", specifier = "==3.5.0" }, { name = "djangorestframework", specifier = "==3.17.1" }, + { name = "drf-spectacular", specifier = "==0.30.0" }, { name = "drf-standardized-errors", specifier = ">=0.12.6" }, { name = "drf-yasg", specifier = ">=1.21.8" }, { name = "google-cloud-recaptcha-enterprise", specifier = ">=1.28.2" }, diff --git a/specs/docstudio-oss.json b/specs/docstudio-oss.json new file mode 100644 index 0000000000..edf3196660 --- /dev/null +++ b/specs/docstudio-oss.json @@ -0,0 +1,479 @@ +{ + "components": { + "schemas": { + "ErrorResponse": { + "properties": { + "message": { + "nullable": true + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "ExecuteRequest": { + "description": "The documents to run, and the options that shape the result.\n\nSupply `files`, `presigned_urls`, or both.", + "properties": { + "custom_data": { + "nullable": true + }, + "files": { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + "hitl_packet_id": { + "nullable": true, + "type": "string" + }, + "hitl_queue_name": { + "nullable": true, + "type": "string" + }, + "include_extracted_text": { + "default": false, + "type": "boolean" + }, + "include_metadata": { + "default": false, + "type": "boolean" + }, + "include_metrics": { + "default": false, + "type": "boolean" + }, + "llm_profile_id": { + "nullable": true, + "type": "string" + }, + "presigned_urls": { + "items": { + "format": "uri", + "type": "string" + }, + "type": "array" + }, + "tags": { + "default": "", + "description": "Comma-separated list of tag names (EX:'tag1,tag2-name,tag3_name')", + "type": "string" + }, + "timeout": { + "default": -1, + "maximum": 300, + "minimum": -1, + "type": "integer" + }, + "use_file_history": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "ExecuteResponse": { + "properties": { + "message": { + "$ref": "#/components/schemas/ExecutionMessage" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "ExecutionMessage": { + "description": "The execution's identity and, once it has finished, its per-file\nresults.", + "properties": { + "error": { + "type": "string" + }, + "execution_id": { + "type": "string" + }, + "execution_status": { + "type": "string" + }, + "result": { + "items": { + "$ref": "#/components/schemas/FileResult" + }, + "nullable": true, + "type": "array" + }, + "status_api": { + "type": "string" + } + }, + "required": [ + "error", + "execution_id", + "execution_status", + "status_api" + ], + "type": "object" + }, + "FileResult": { + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "file": { + "type": "string" + }, + "file_execution_id": { + "type": "string" + }, + "metadata": {}, + "metrics": {}, + "result": {}, + "status": { + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + }, + "StatusResponse": { + "properties": { + "message": { + "items": { + "$ref": "#/components/schemas/FileResult" + }, + "nullable": true, + "type": "array" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + }, + "securitySchemes": { + "deploymentKey": { + "description": "The API deployment's own key.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "title": "Unstract API", + "version": "v1" + }, + "openapi": "3.0.3", + "paths": { + "/deployment/api/{org_name}/{api_name}/": { + "get": { + "description": "Read the result of a previously started execution.\n\nThis read is one-shot: the first call that observes a completed execution acknowledges it and the stored result is discarded, so every later call for that execution answers 406. Poll while the execution is pending, and keep the payload of the call that returns it \u2014 it cannot be fetched again.", + "operationId": "status", + "parameters": [ + { + "description": "API deployment name.", + "in": "path", + "name": "api_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + }, + { + "in": "query", + "name": "execution_id", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "in": "query", + "name": "include_extracted_text", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_metadata", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_metrics", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "Organization identifier.", + "in": "path", + "name": "org_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The request failed validation." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The API key is not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No API key was supplied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No such active deployment." + }, + "406": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The result was already consumed by an earlier call." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too many concurrent executions; retry later." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "" + } + }, + "security": [ + { + "deploymentKey": [] + } + ], + "tags": [ + "deployment" + ] + }, + "post": { + "description": "Execute an API deployment against one or more documents.\n\nSupply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both \u2014 a request carrying neither is rejected, and the two together may not exceed 32 documents.\n\nWith the default `timeout` of -1 the call returns as soon as the execution is queued; read the outcome from the status endpoint.", + "operationId": "execute", + "parameters": [ + { + "description": "API deployment name.", + "in": "path", + "name": "api_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + }, + { + "description": "Organization identifier.", + "in": "path", + "name": "org_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ExecuteRequest" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The request failed validation." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The API key is not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No API key was supplied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No such active deployment." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The deployment has no active API key." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too many concurrent executions; retry later." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "" + } + }, + "security": [ + { + "deploymentKey": [] + } + ], + "tags": [ + "deployment" + ] + } + } + }, + "tags": [ + { + "description": "Run an API deployment against one or more documents and poll the result.", + "name": "deployment" + } + ] +}