Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
contains_tool_not_found_error,
)
from api_v2.models import APIDeployment
from api_v2.openapi_schema import DEPLOYMENT_EXECUTION_SCHEMA
from api_v2.rate_limiter import APIDeploymentRateLimiter
from api_v2.serializers import (
APIDeploymentListSerializer,
Expand All @@ -50,6 +51,7 @@
logger = logging.getLogger(__name__)


@DEPLOYMENT_EXECUTION_SCHEMA
class DeploymentExecution(views.APIView):
def initialize_request(self, request: Request, *args: Any, **kwargs: Any) -> Request:
"""To remove csrf request for public API.
Expand Down
29 changes: 29 additions & 0 deletions backend/api_v2/deployment_spec_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""URLconf the published OpenAPI spec is generated against.

Each entry is an included sub-urlconf: generating against one directly yields
paths without the prefix it is mounted at, i.e. a spec describing URLs the
server does not serve. The mounts are selected out of the served urlconf
rather than restated, so moving one moves the generated paths with it.

Widening the spec to another endpoint means annotating its view with
``@extend_schema`` and adding its urlconf here.
"""

from django.core.exceptions import ImproperlyConfigured

from backend import base_urls

SPEC_URLCONFS = ("api_v2.execution_urls",)

urlpatterns = [
entry
for entry in base_urls.urlpatterns
if getattr(getattr(entry, "urlconf_name", None), "__name__", None) in SPEC_URLCONFS
]

missing = set(SPEC_URLCONFS) - {entry.urlconf_name.__name__ for entry in urlpatterns}
if missing:
raise ImproperlyConfigured(
f"{', '.join(sorted(missing))} is not mounted in backend.base_urls; the "
"spec would be generated for routes the server does not serve."
)
104 changes: 104 additions & 0 deletions backend/api_v2/management/commands/generate_docstudio_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 13] — The drift gate's outcome depends on ambient API_DEPLOYMENT_PATH_PREFIX, enforced only by this docstring

Generated paths carry settings.API_DEPLOYMENT_PATH_PREFIX (backend/backend/base_urls.py:20), read from the environment at import with a default of deployment. A developer whose environment overrides it sees the drift test fail, regenerates as instructed, and commits a spec whose paths carry a private prefix — the test then passes on the wrong artifact and the downstream SDK repos are generated from it.

Low because nothing in-repo sets the variable (no .env sample, no compose file, and the rig's backend_test_env doesn't pin it) and a prefix change is visible in the specs/ diff.

Fix: assert the rendered path prefix equals the default, or override the setting for the duration of render_spec().

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"
# Named in every failure message: the repos that regenerate from this file are
# the ones a spec change actually breaks, and nothing there watches this repo.
DOWNSTREAM = (
"The published client (Zipstack/unstract-python-client) and the CLI "
"(Zipstack/unstract-cli) are generated from this file — raise the matching "
"PRs there for anything that changes an operation id, a tag or a schema."
)


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 16] — This comment misstates spectacular's diagnostic severity and what it emits

The comment says spectacular "downgrades 'unable to guess serializer' to a warning and writes a plausible, wrong operation" (restated at test_docstudio_spec.py:55-57). In the pinned 0.30.0, that message is emitted through error() so it lands in _error_cache, not _warn_cache; and serializer resolution returns None, so the operation is published with no request body and a "No response body" response — not a guessed shape.

The guard is correct because it checks both caches. But a maintainer debugging a future failure looks in the wrong bucket and expects a fabricated schema that is never there.

Evidence: drf_spectacular/openapi.py:1269-1273 and :1498-1499.

Also here: docstudio (:23, :25) appears nowhere else in the repo, while the spec's own info.title is "Unstract API" — a maintainer has no path from either name to the other. Consider generate_api_deployment_spec / specs/api-deployment-oss.json, unless it's an established product name.

# writes a plausible, wrong operation. Nothing downstream can tell that
# apart from an annotation that is simply thin.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 3] — The generation gate rejects guessed shapes but never checks the spec is legal OpenAPI

GENERATOR_STATS only fires on spectacular's own inferences. Hand-written fragments pass through verbatim — PATH_SEGMENT (openapi_schema.py:74) and APPEND_COMPONENTS.securitySchemes (settings/base.py:665-673). A typo in either emits no diagnostic, the drift test compares equal, CI is green, and the invalid spec ships to the two repos DOWNSTREAM names — breaking as a generator crash with nothing here pointing at the cause.

Verified — injecting "patern" and "htttp" typos: both validate INVALID against the bundled openapi_3_0_schema.json, while the spec as committed validates clean. So the check would have caught them and nothing else does.

Fix: call drf_spectacular.validation.validate_schema(schema) in render_spec() after the GENERATOR_STATS block, re-raising as SpecGenerationFailed.

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/` "
f"and commit the result.\n\n{DOWNSTREAM}"
)
self.stdout.write(f"{out} is up to date")
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 13] — The --check branch and the write branch are exercised by nothing

--check is invoked by no CI job (ci-test.yaml runs only the tox tiers), no pre-commit hook, and no test. The actual drift gate in CI is test_committed_spec_matches_the_code, which reimplements the same comparison. So the branch the module docstring advertises as the drift command is untested, and the write branch's summary arithmetic at :94-103 is likewise unexercised.

Fix: exercise handle() via call_command (up-to-date, drifted, and write-to-tmp via --out), or drop --check and point the docstring at the pytest gate that actually runs.

(For the record, the drift gate itself is real — tests/groups.yaml:85-94 collects backend/** by glob, and CI shows unit-backend at 1009 passed. Only this branch is dead.)


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"
)
161 changes: 161 additions & 0 deletions backend/api_v2/openapi_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""OpenAPI annotations for the API deployment endpoints.

The serializers here shape the published spec only; none of them is used to
parse a request or build a response. They live outside ``serializers.py`` so
that nothing at request time imports one by accident.

Their docstrings are published as the client-facing model descriptions, so
they are written for the caller rather than the maintainer.
"""

from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_serializer,
extend_schema_view,
)
from rest_framework import serializers

from api_v2.serializers import (
APIExecutionResponseSerializer,
ExecutionQuerySerializer,
ExecutionRequestSerializer,
)


# Declares no field of its own, so a change to the real serializer moves the
# spec. It exists to carry a caller-facing description and a stable name.
@extend_schema_serializer(component_name="ExecuteRequest")
class ExecuteRequest(ExecutionRequestSerializer):
"""The documents to run, and the options that shape the result.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 1, 3, 7] — The OSS spec advertises two request fields that always 400 in OSS

ExecuteRequest inherits the whole of ExecutionRequestSerializer, so the committed docstudio-oss.json publishes hitl_queue_name and hitl_packet_id. In an OSS install both are unconditionally rejected — validate_hitl_queue_name catches ModuleNotFoundError on pluggable_apps.manual_review_v2 and raises ValidationError (serializers.py:272-305).

An OSS SDK user gets a 400 with an enterprise sales message from a parameter the SDK told them exists.

Fix: @extend_schema_serializer(exclude_fields=("hitl_queue_name","hitl_packet_id")), or gate on apps.is_installed(...).

Open question: is a cloud spec generated from this same command? That decides flat-exclude vs conditional.


Supply `files`, `presigned_urls`, or both.
"""


class FileResult(serializers.Serializer):
file = serializers.CharField()
file_execution_id = serializers.CharField(required=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 7] — file_execution_id is declared non-nullable but the DTO defaults it to None

FileExecutionResult.to_json always emits the key and the field defaults to None (endpoint_v2/dto.py:125,141), sourced from file_processing_result.file_execution_id (workers/shared/utils/api_result_cache.py:156).

UNVERIFIED — I did not find a producer that leaves it unset, so this is a question rather than an asserted defect: is there a path where a cached file result carries no file_execution_id? Please confirm before changing anything on the strength of it.

status = serializers.CharField(required=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 7] — FileResult declares a metrics the server never emits and omits extracted_text which it does

Metrics live one level down at item["result"]["metrics"]remove_result_metrics pops it off the inner dict (dto.py:163-169) — so no writer produces a top-level item["metrics"] and file_result.metrics is always empty.

Separately, promote_extracted_text sets item["extracted_text"] (dto.py:143-154) whenever include_extracted_text=true, and that field is absent here — so a typed client drops the payload the caller explicitly opted in for.

Verified — traced both API-result writers (endpoint_v2/destination.py:536-556, endpoint_v2/dto.py:137-146) and grepped every metrics/extracted_text write across backend/ and workers/.

Fix: drop metrics; add extracted_text = CharField(required=False, allow_null=True).

result = serializers.JSONField(required=False)
metadata = serializers.JSONField(required=False)
metrics = serializers.JSONField(required=False)
error = serializers.CharField(required=False, allow_null=True)


class ExecutionMessage(APIExecutionResponseSerializer):
"""The execution's identity and, once it has finished, its per-file
results.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 7, 3] — error and status_api are published required non-nullable, but every successful 200 sends error: null

ExecutionResponse.__post_init__ (workflow_manager/workflow_v2/dto.py:72) forces falsy error to None, and DRF emits None verbatim for a bare CharField. So the happy path violates the published schema. status_api is null on both error returns in deployment_helper.py:300-307 and :361-367 — the 422 and 500 bodies.

Verified — the real serializer replayed over the real dataclass:

{'execution_status':'PENDING','status_api':'/deployment/...','error':None,'result':None}

A pydantic/Go/Java client raises on the success response.

The comment just above at :52-55 diagnoses this exact hazard for result and fixes it one field over — error is null more often than result is.

Fix: restate both as CharField(required=False, allow_null=True), regenerate.

# Restated because the real declaration is an untyped JSONField, and
# because a pending execution sends `result: null`, which a generated
# deserialiser iterates and crashes on without allow_null.
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 7, 3, 2] — Every error response in the spec has a shape the server never sends

ErrorResponse publishes {status, message}, but 400/401/403/404/409/429 are all raised as APIException and routed through drf_standardized_errors (backend/middleware/exception.py:48), which emits {type, errors:[{code, detail, attr}]} — a disjoint shape. A generated SDK models both documented fields, finds them permanently absent on every failure, and has nothing to read a message from.

{status, message} fits only the hand-built 406/422/500 bodies (api_deployment_views.py:196-228).

Verified — the pinned handler run against this repo's own exceptions:

401 {'type':'client_error','errors':[{'code':'error','detail':'Unauthorized','attr':None}]}
400 {'type':'validation_error','errors':[{'code':'invalid','detail':'You must provide at least one file or presigned URL.','attr':None}]}

Corroborated in-repo by backend/middleware/test_exception.py:41-43 and frontend/src/hooks/useExceptionHandler.jsx:35.

Fix: the repo already ships drf-standardized-errors==0.15.0 with drf_standardized_errors/openapi.py for exactly this — use its AutoSchema as DEFAULT_SCHEMA_CLASS; or minimally redefine ErrorResponse to the handler's shape and keep {status, message} for 406/422/500 only.



# Restates the route's own pattern so a client rejects 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": []}]

# A client generated without these 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 "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 7, 16] — STATUS_DESCRIPTION tells callers to poll without saying the pending poll answers 422

"Poll while the execution is pending" reads as a 200-returning loop. api_deployment_views.py:230-246 starts response_status at HTTP_422_UNPROCESSABLE_ENTITY and raises it to 200 only on CeleryTaskState.COMPLETED.

Generated SDKs raise on 4xx by default, so the documented polling loop throws on every iteration until completion. The shape is declared (422: StatusResponse) so nothing is strictly wrong — the caller is simply not told the normal path is a non-2xx.

Fix: one sentence — a still-running execution answers 422 with the current status; only a completed one answers 200.

"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."
)


# Generated clients take their command names, module paths and request shapes
# from here, so this is part of the public API surface.
DEPLOYMENT_EXECUTION_SCHEMA = 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 7] — execute can return 413/502/504 and arbitrary upstream statuses the spec does not declare

fetch_presigned_file raises 413 on oversize (deployment_helper.py:597-601), 504 on timeout (:665-668), 502 on connection error (:669-674), and — most consequentially — the upstream response's own status verbatim (:659-662, status_code = e.response.status_code).

So an expired S3 signature surfaces as a 403 whose spec description reads "No API key was supplied.", and an S3 404 as "No such active deployment." Undeclared statuses reach a generated client as an unmodelled response.

Fix: declare 413/502/504, and reword the 403/404 descriptions so they don't assert a cause the endpoint can't guarantee. Better still, normalise upstream statuses to a single 502 in fetch_presigned_file so the published contract is closed.

409: OpenApiResponse(
ErrorResponse, description="The deployment has no active API key."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 7] — Two declared statuses this endpoint cannot return

409 on execute and 429 on status are dead branches in every generated client. NoActiveAPIKeyError is raised only at api_deployment_views.py:399 (download_postman_collection) and pipeline_v2/views.py:253 — never from DeploymentExecution.post. RateLimitExceeded is raised only at api_deployment_views.py:118, inside post — never on the get path.

Fix: drop 409 from post; split 429 out of DEPLOYMENT_ERRORS so it applies to post only.

),
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,
),
)
11 changes: 10 additions & 1 deletion backend/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -320,7 +329,7 @@ def validate_custom_data(self, value):
return value

files = ListField(
child=FileField(),
child=UploadField(),
required=False,
allow_empty=True,
)
Expand Down
Loading
Loading