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
3 changes: 2 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ psql -d unstract_db -U unstract_dev

## API Docs

The OpenAPI spec for the API deployment endpoints is committed at
The OpenAPI spec for the publicly served API -- the API deployment endpoints,
and the organisation-less `whoami` -- is committed at
[`specs/docstudio-oss.json`](../specs/docstudio-oss.json) and is the contract the published
clients and their generated SDKs are built from. It is not served at runtime — regenerate it in
the same PR as any route, serializer or schema-annotation change:
Expand Down
2 changes: 1 addition & 1 deletion backend/api_v2/deployment_spec_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from backend import base_urls

SPEC_URLCONFS = ("api_v2.execution_urls",)
SPEC_URLCONFS = ("api_v2.execution_urls", "platform_api.whoami_urls")

urlpatterns = [
entry
Expand Down
31 changes: 15 additions & 16 deletions backend/api_v2/management/commands/generate_docstudio_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
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 generation refuses
to produce a spec mounted anywhere but the public default: the committed
artifact describes the deployment as it is served publicly, not as one
installation chooses to mount it.
The generated paths carry ``API_DEPLOYMENT_PATH_PREFIX`` or ``PATH_PREFIX``
depending on the mount, so generation refuses to produce a spec mounted anywhere
but the public defaults: the committed artifact describes the API as it is
served publicly, not as one installation chooses to mount it.
"""

import json
Expand All @@ -25,10 +25,12 @@
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"
# The mount the deployment is served at publicly. `API_DEPLOYMENT_PATH_PREFIX`
# can move it per installation, and a spec carrying a private prefix would send
# every generated client to a URL only that installation answers.
PUBLISHED_PATH_PREFIX = "deployment"
# The mounts these routes are served at publicly. `API_DEPLOYMENT_PATH_PREFIX`
# and `PATH_PREFIX` can move them per installation, and a spec carrying a
# private prefix would send every generated client to a URL only that
# installation answers. Written as literals rather than read from settings, so
# an override fails the gate rather than being baked into the artifact.
PUBLISHED_PATH_PREFIXES = ("deployment", "api/v1/unstract")
# 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 = (
Expand Down Expand Up @@ -69,16 +71,13 @@ def render_spec() -> str:
f"API nobody implements:\n{diagnostics}"
)

off_prefix = [
path
for path in schema["paths"]
if not path.startswith(f"/{PUBLISHED_PATH_PREFIX}/")
]
published = tuple(f"/{prefix}/" for prefix in PUBLISHED_PATH_PREFIXES)
off_prefix = [path for path in schema["paths"] if not path.startswith(published)]
if off_prefix:
raise SpecGenerationFailed(
f"Generated paths are not under /{PUBLISHED_PATH_PREFIX}/: "
f"{', '.join(sorted(off_prefix))}. Unset API_DEPLOYMENT_PATH_PREFIX "
f"and regenerate."
f"Generated paths are outside the published mounts "
f"({', '.join(published)}): {', '.join(sorted(off_prefix))}. Unset "
f"API_DEPLOYMENT_PATH_PREFIX and PATH_PREFIX and regenerate."
)

# Hand-written fragments (path parameter schemas, security schemes) reach
Expand Down
196 changes: 189 additions & 7 deletions backend/api_v2/tests/test_docstudio_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from drf_spectacular.drainage import warn
from drf_spectacular.generators import SchemaGenerator
from middleware.exception import drf_logging_exc_handler
from platform_api.models import ApiKeyPermission
from rest_framework.exceptions import APIException, ValidationError
from rest_framework.test import APIRequestFactory
from workflow_manager.endpoint_v2.dto import FileExecutionResult
Expand All @@ -41,6 +42,22 @@
#: up from the extraction metadata when the request asks for it.
_PROMOTED_FILE_RESULT_FIELDS = {"extracted_text"}

#: Responses whose published example already contradicted its schema before
#: this check existed. All three are the deployment operations declaring a
#: non-error body for a status the standardized-errors schema class also
#: injects a handler-shaped example for. Recorded rather than silently skipped:
#: the check below fails on any *new* instance, and this list is the debt.
_KNOWN_EXAMPLE_DIVERGENCES = {
("status", "406", "NotAcceptable"),
("status", "500", "APIException"),
("execute", "500", "APIException"),
}

#: The operations served by an API deployment, as opposed to the platform-key
#: operations that describe the account. They authenticate differently and can
#: fail differently, so several checks below split on this.
DEPLOYMENT_OPERATIONS = {"execute", "status"}


def _committed() -> dict:
return json.loads(DEFAULT_OUT.read_text())
Expand Down Expand Up @@ -89,6 +106,30 @@ def guessing_generator(self, request=None, public=False) -> dict:
render_spec()


def test_a_path_outside_the_published_mounts_fails_generation(monkeypatch) -> None:
"""The gate the diff widened, exercised on the branch it protects.

Widening it from one prefix to two is exactly the edit that could admit
everything; the accept direction is covered incidentally by every other
test here, and only this one covers the refusal.
"""

def off_prefix_generator(self, request=None, public=False) -> dict:
# Otherwise-valid, so the OpenAPI-validity gate below cannot be what
# raises: matching on the path alone passed even with the prefix gate
# removed, because the validity error quotes the instance back.
return {
"openapi": "3.0.3",
"info": {"title": "t", "version": "v1"},
"paths": {"/private/api/{org_name}/": {}},
}

monkeypatch.setattr(SchemaGenerator, "get_schema", off_prefix_generator)

with pytest.raises(SpecGenerationFailed, match="outside the published mounts"):
render_spec()


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.
Expand All @@ -115,23 +156,62 @@ def test_spec_documents_the_deployment_operations() -> None:
assert "deployment" in [tag["name"] for tag in spec["tags"]]


def test_operations_require_the_deployment_key() -> None:
def test_every_operation_names_the_credential_it_takes() -> None:
"""Without this the unset DRF authentication default is published as
though it were a decision, and no generated client can authenticate.

Which credential differs by operation -- a deployment key runs a
deployment, a platform key describes itself -- so what is pinned here is
that each operation names exactly one, and that the scheme it names is
declared and is a bearer token.
"""
spec = _committed()
scheme = spec["components"]["securitySchemes"]["deploymentKey"]
schemes = spec["components"]["securitySchemes"]

assert (scheme["type"], scheme["scheme"]) == ("http", "bearer")
for path, method, operation in _operations(spec):
assert operation["security"] == [{"deploymentKey": []}], f"{method} {path}"
security = operation["security"]
assert len(security) == 1, f"{method} {path}"
(requirement,) = security
(name,) = requirement
assert requirement[name] == [], f"{method} {path}"
assert (schemes[name]["type"], schemes[name]["scheme"]) == (
"http",
"bearer",
), f"{method} {path}"


def test_the_deployment_operations_take_the_deployment_key() -> None:
"""The credential each operation names is part of its contract, so the
pairing is pinned rather than left to the loop above.
"""
for path, method, operation in _operations(_committed()):
if operation["operationId"] in DEPLOYMENT_OPERATIONS:
assert operation["security"] == [{"deploymentKey": []}], f"{method} {path}"
else:
assert operation["security"] == [{"platformKey": []}], f"{method} {path}"


def test_clients_can_branch_on_every_failure_they_will_see() -> None:
"""Every operation authenticates and can fail on the server, so these three
are the branches a client needs whatever it is calling.
"""
for path, method, operation in _operations(_committed()):
assert {"400", "401", "403", "404", "500"} <= set(
operation["responses"]
), f"{method} {path}"
assert {"401", "403", "500"} <= set(operation["responses"]), f"{method} {path}"


def test_the_deployment_operations_document_a_rejected_request_and_a_missing_one() -> (
None
):
"""Kept off the universal check above: a request carrying no body and
naming no resource cannot be malformed or miss its target, and documenting
a status an operation cannot return hands clients a dead branch.
"""
for path, method, operation in _operations(_committed()):
declared = {"400", "404"} & set(operation["responses"])
if operation["operationId"] in DEPLOYMENT_OPERATIONS:
assert declared == {"400", "404"}, f"{method} {path}"
else:
assert not declared, f"{method} {path}"


def test_only_the_execution_endpoint_documents_the_statuses_only_it_returns() -> None:
Expand Down Expand Up @@ -216,6 +296,108 @@ def test_the_status_read_documents_the_two_keys_it_returns() -> None:
)


def test_spec_documents_the_identity_operation() -> None:
spec = _committed()
documented = {operation["operationId"] for _, _, operation in _operations(spec)}

assert "whoami" in documented
assert "identity" in [tag["name"] for tag in spec["tags"]]


def test_the_identity_read_documents_the_keys_it_returns() -> None:
"""The view builds its body literally, so the spec is the only place the
set is written down.
"""
whoami = _schema("WhoAmIResponse")
fields = {"organization_id", "organization_name", "permission", "key_name"}

assert set(whoami["properties"]) == fields
# All four are read off a key row that always has them, so a client can
# treat every one as present rather than guarding each.
assert set(whoami["required"]) == fields


def test_the_documented_permission_tiers_are_the_ones_the_model_defines() -> None:
"""A tier added to the model but not the spec reaches clients as a value
their generated enum rejects.
"""
assert _schema("ApiKeyPermission")["enum"] == list(ApiKeyPermission.values)


def test_the_identity_reads_errors_are_the_shape_the_middleware_sends() -> None:
"""`whoami` authenticates in middleware, which answers with a bare
`message` and does not reach the project exception handler for its credential failures -- so it must not
publish the handler's `{type, errors[]}` shape the way the deployment
operations legitimately do.

Paired with `test_a_rejection_carries_the_body_the_spec_publishes` in
`platform_api`, which pins the same claim against the wire.
"""
spec = _committed()
reads = [
(path, operation)
for path, _, operation in _operations(spec)
if operation["operationId"] == "whoami"
]

# Guarded like its sibling below: without this the whole check is skipped
# the day the operation id moves.
assert reads
for path, operation in reads:
for code in ("401", "403"):
ref = operation["responses"][code]["content"]["application/json"]["schema"][
"$ref"
]
assert ref.endswith("/PlatformKeyError"), f"{code} on {path}: {ref}"
assert set(_schema("PlatformKeyError")["properties"]) == {"message"}


def test_no_published_example_contradicts_its_own_schema() -> None:
"""The standardized-errors schema class appends an example of the exception
handler's body to every 4xx/5xx, keyed on the status code alone -- so an
operation that overrides the schema keeps examples describing the shape it
replaced, and the artifact contradicts itself in one media-type object.

Checked structurally rather than by name: any response declaring a body
other than `ErrorResponse` must carry no handler-shaped example.
"""
for path, method, operation in _operations(_committed()):
for code, response in operation["responses"].items():
media = response.get("content", {}).get("application/json", {})
ref = media.get("schema", {}).get("$ref", "")
if ref.endswith("/ErrorResponse"):
continue
for name, example in media.get("examples", {}).items():
if (
operation["operationId"],
code,
name,
) in _KNOWN_EXAMPLE_DIVERGENCES:
continue
assert "errors" not in example.get("value", {}), (
f"{method} {path} {code}: example {name!r} shows the handler "
f"body, but the response declares {ref.split('/')[-1]!r}"
)


def test_the_identity_read_asks_for_no_organisation() -> None:
"""Resolving the organisation from the key is the whole point: a path
parameter here would mean the caller had to know the answer first.
"""
reads = [
(path, operation)
for path, _, operation in _operations(_committed())
if operation["operationId"] == "whoami"
]

# Guarded like its sibling at `test_the_one_shot_read_...`: an unguarded
# loop passes by finding nothing the day the operation is renamed.
assert reads
for path, operation in reads:
assert "{" not in path, path
assert not operation.get("parameters"), path


@pytest.mark.parametrize(
"exc",
[APIException("Unauthorized"), ValidationError("at least one file is required")],
Expand Down
15 changes: 15 additions & 0 deletions backend/backend/base_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@

# Combine the URL patterns
urlpatterns = [
# Organisation-less, and so mounted ahead of the tenant urlconf rather than
# relying on falling through it. Nothing is shadowed today because no tenant
# urlconf declares `whoami/`; mounting first is what makes this route win if
# one ever does.
#
# Note this is not the only URL that reaches the view. `OrganizationMiddleware`
# strips the organisation segment before routing, so
# `/api/v1/unstract/<org>/whoami/` is rewritten to exactly this path and
# resolves here too -- with `organization_id` set, so the key-belongs-to-org
# check in `CustomAuthMiddleware` additionally applies. That alias is
# stricter, not looser; `test_whoami.py` covers both forms.
path(
f"{settings.TENANT_SUBFOLDER_PREFIX}/",
include("platform_api.whoami_urls"),
),
path(
f"{settings.TENANT_SUBFOLDER_PREFIX}/",
include((tenant_urls, "tenant"), namespace="tenant"),
Expand Down
36 changes: 31 additions & 5 deletions backend/backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,12 +671,24 @@ def filter(self, record):
"type": "http",
"scheme": "bearer",
"description": "The API deployment's own key.",
}
},
"platformKey": {
"type": "http",
"scheme": "bearer",
"description": (
"An organisation-wide platform API key, minted under "
"Settings. It carries the organisation it belongs to, but "
"cannot execute an API deployment."
),
},
}
},
# Without this the enum component is named after the field that holds it,
# and generated clients get a class called `TypeEnum`.
"ENUM_NAME_OVERRIDES": {"ErrorType": "api_v2.openapi_schema.ERROR_TYPES"},
"ENUM_NAME_OVERRIDES": {
"ErrorType": "api_v2.openapi_schema.ERROR_TYPES",
"ApiKeyPermission": "platform_api.models.ApiKeyPermission.choices",
},
# Group descriptions generated clients show in their help; without this
# the spec has no root `tags` array for the text to live in.
"TAGS": [
Expand All @@ -686,7 +698,14 @@ def filter(self, record):
"Run an API deployment against one or more documents and poll "
"the result."
),
}
},
{
"name": "identity",
"description": (
"Resolve what a platform API key is scoped to, so a client can "
"discover its organisation rather than being told it."
),
},
],
}

Expand All @@ -709,8 +728,15 @@ def filter(self, record):
# Whitelisting health check API
WHITELISTED_PATHS.append("/health")

# These path will work without organization in request
ORGANIZATION_MIDDLEWARE_WHITELISTED_PATHS = []
# These path will work without organization in request.
# `whoami` resolves the organisation from the API key itself, so it carries no
# organisation segment -- without this the middleware would read `whoami` as one.
# Note this is not WHITELISTED_PATHS: the endpoint still authenticates.
# Anchored: `re.match` is a prefix test, so without the `$` an organisation
# literally named `whoami` would have its entire API treated as organisation-less.
ORGANIZATION_MIDDLEWARE_WHITELISTED_PATHS = [
rf"^/{PATH_PREFIX}/unstract/whoami/$",
]

# Social Auth Settings
SOCIAL_AUTH_LOGIN_REDIRECT_URL = f"{WEB_APP_ORIGIN_URL}/oauth-status/?status=success"
Expand Down
Loading
Loading