Skip to content
Merged
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
45 changes: 40 additions & 5 deletions backend/api_v2/api_deployment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import uuid
from typing import Any

from django.db.models import F, OuterRef, QuerySet, Subquery
from django.db.models import Count, F, IntegerField, OuterRef, QuerySet, Subquery
from django.db.models.functions import Coalesce
from django.http import HttpResponse
from permissions.membership_views import OwnerManagementMixin
from permissions.permission import IsOwner, IsOwnerOrSharedUserOrSharedToOrg
from permissions.resource_share_views import ResourceShareManagementMixin
from permissions.roles import ResourceRole
from platform_api.openapi_schema import PlatformKeyAutoSchema
from plugins import get_plugin
from prompt_studio.prompt_studio_registry_v2.models import PromptStudioRegistry
from rest_framework import serializers, status, views, viewsets
Expand All @@ -33,7 +35,10 @@
contains_tool_not_found_error,
)
from api_v2.models import APIDeployment
from api_v2.openapi_schema import DEPLOYMENT_EXECUTION_SCHEMA
from api_v2.openapi_schema import (
API_DEPLOYMENT_LIST_SCHEMA,
DEPLOYMENT_EXECUTION_SCHEMA,
)
from api_v2.rate_limiter import APIDeploymentRateLimiter
from api_v2.serializers import (
APIDeploymentListSerializer,
Expand Down Expand Up @@ -246,10 +251,17 @@
)


@API_DEPLOYMENT_LIST_SCHEMA
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
class APIDeploymentViewSet(
OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet
):
pagination_class = CustomPagination

# For schema generation only; get_queryset replaces it on every request.
queryset = APIDeployment.objects.none()

# Error examples have to match what the auth middleware sends.
schema = PlatformKeyAutoSchema()
notification_resource_name_field = "display_name"

def get_notification_resource_type(self, resource: Any) -> str | None:
Expand All @@ -275,19 +287,42 @@
.order_by("-created_at")
.values("created_at")[:1]
)
run_count_subquery = (
WorkflowExecution.objects.filter(pipeline_id=OuterRef("id"))
.values("pipeline_id")
.annotate(total=Count("id"))
.values("total")
)

# Avoid per-row queries for owner/co-owner + creator fields in list views
queryset = (
APIDeployment.objects.for_user(self.request.user)
.select_related("created_by")
.prefetch_related("memberships__user")
.annotate(last_run_time_annotated=Subquery(last_run_subquery))
.order_by(F("last_run_time_annotated").desc(nulls_last=True))
.annotate(
last_run_time_annotated=Subquery(last_run_subquery),
run_count_annotated=Coalesce(
Subquery(run_count_subquery, output_field=IntegerField()), 0
),
)
# `pk` last because the primary ordering ties on every deployment
# that has never run, and a paging client would then see a row
# twice or not at all.
.order_by(F("last_run_time_annotated").desc(nulls_last=True), "pk")
)

# Filter by workflow ID if provided
# TODO: replace the hand-read params and their OpenApiParameter

Check warning on line 314 in backend/api_v2/api_deployment_views.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaCFzpvusgu35AS-whm8&open=AaCFzpvusgu35AS-whm8&pullRequest=2278
# restatements with a FilterSet so the spec cannot drift from the code
workflow_filter = self.request.query_params.get("workflow", None)
if workflow_filter:
try:
uuid.UUID(workflow_filter)
except ValueError:
# Django raises on evaluation, past the handler that turns a bad
# request into a 400.
raise serializers.ValidationError(
{"workflow": "Must be a valid UUID."}
) from None
queryset = queryset.filter(workflow_id=workflow_filter)

# Search by display name
Expand Down
14 changes: 14 additions & 0 deletions backend/api_v2/deployment_spec_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
``@extend_schema`` and adding its urlconf here.
"""

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.urls import path

from api_v2.api_deployment_views import APIDeploymentViewSet
from backend import base_urls

SPEC_URLCONFS = ("api_v2.execution_urls", "platform_api.whoami_urls")
Expand All @@ -27,3 +30,14 @@
f"{', '.join(sorted(missing))} is not mounted in backend.base_urls; the "
"spec would be generated for routes the server does not serve."
)

# Restated rather than selected: this route is served from a urlconf carrying
# routes that are not published, and only its GET is. Both halves of the
# restatement are held to the served route by `test_docstudio_spec`.
urlpatterns += [
path(
f"{settings.TENANT_SUBFOLDER_PREFIX}/api/deployment/",
APIDeploymentViewSet.as_view({"get": "list"}),
name="api_deployment",
),
]
47 changes: 46 additions & 1 deletion backend/api_v2/management/commands/generate_docstudio_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
"""

import json
import re
from pathlib import Path
from typing import Any

from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from drf_spectacular.drainage import GENERATOR_STATS
from drf_spectacular.generators import SchemaGenerator
Expand All @@ -35,7 +37,11 @@
# is exactly `TENANT_SUBFOLDER_PREFIX`, so with a `startswith` over the union an
# `API_DEPLOYMENT_PATH_PREFIX` pointed anywhere under the tenant mount --
# `api/v1/unstract/deploy`, say -- passed the gate this comment says it fails.
PUBLISHED_PATH_PREFIXES = ("deployment", "api/v1/unstract/whoami")
PUBLISHED_PATH_PREFIXES = (
"deployment",
"api/v1/unstract/whoami",
"api/v1/unstract/{org_id}/api/deployment",
)
# 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 All @@ -44,6 +50,43 @@
"PRs there for anything that changes an operation id, a tag or a schema."
)

# A literal for the same reason as `PUBLISHED_PATH_PREFIXES`.
TENANT_MOUNT = "/api/v1/unstract/"
ORG_SEGMENT = "{org_id}"
HTTP_METHODS = frozenset(
{"get", "put", "post", "delete", "options", "head", "patch", "trace"}
)
ORG_SEGMENT_PARAMETER = {
"in": "path",
"name": "org_id",
"required": True,
"schema": {"type": "string"},
"description": (
"The organisation the request is scoped to, as `whoami` reports it in "
"`organization_id`."
),
}


def _restore_organisation_segment(schema: dict[str, Any]) -> None:
"""Put back the organisation segment the router never sees.

`OrganizationMiddleware` strips it before routing, so paths taken from the
URLconf are not the ones callers send. The routes genuinely served without
it are the ones that setting whitelists, so it decides this too.
"""
for url in [url for url in schema["paths"] if url.startswith(TENANT_MOUNT)]:
if any(
re.match(whitelisted, url)
for whitelisted in settings.ORGANIZATION_MIDDLEWARE_WHITELISTED_PATHS
):
continue
item = schema["paths"].pop(url)
for method, operation in item.items():
if method in HTTP_METHODS:
operation.setdefault("parameters", []).append(dict(ORG_SEGMENT_PARAMETER))
schema["paths"][f"{TENANT_MOUNT}{ORG_SEGMENT}/{url[len(TENANT_MOUNT):]}"] = item


class SpecGenerationFailed(CommandError):
"""Raised when the generator had to guess."""
Expand Down Expand Up @@ -76,6 +119,8 @@ def render_spec() -> str:
f"API nobody implements:\n{diagnostics}"
)

_restore_organisation_segment(schema)

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:
Expand Down
123 changes: 114 additions & 9 deletions backend/api_v2/openapi_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
extend_schema_serializer,
extend_schema_view,
)
from platform_api.openapi_schema import PlatformKeyError
from rest_framework import serializers

from api_v2.models import API_NAME_MAX_LENGTH, DESCRIPTION_MAX_LENGTH
from api_v2.serializers import (
APIDeploymentListSerializer,
APIExecutionResponseSerializer,
ExecutionQuerySerializer,
ExecutionRequestSerializer,
Expand Down Expand Up @@ -170,8 +173,12 @@ class ErrorResponse(serializers.Serializer):
),
}

EXECUTE_SUMMARY = "Execute an API deployment against documents"

EXECUTE_DESCRIPTION = (
"Execute an API deployment against one or more documents.\n\n"
"Runs an API deployment. Takes a deployment key — either the "
"deployment's own key, or a global API deployment key that has access "
"to it.\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 "
Expand All @@ -180,15 +187,16 @@ class ErrorResponse(serializers.Serializer):
"execution is queued; read the outcome from the status endpoint."
)

STATUS_SUMMARY = "Read the result of an execution"

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."
"\n\nA still-running execution answers 422 carrying its current `status`, "
"so a polling loop should treat 422 as the normal reply and stop on 200. "
"Clients that raise on any non-2xx need to allow for that."
"Reads a previously started execution, taking the same deployment key "
"that ran it. The read is one-shot: the first call that observes a completed "
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
"execution acknowledges it and the stored result is discarded, so every "
"later call for that execution answers 406. Keep the payload of the call "
"that returns it — it cannot be fetched again.\n\n"
"A still-running execution answers 422 carrying its current `status`, so a "
"polling loop should treat 422 as the normal reply and stop on 200."
)


Expand All @@ -197,6 +205,7 @@ class ErrorResponse(serializers.Serializer):
DEPLOYMENT_EXECUTION_SCHEMA = extend_schema_view(
post=extend_schema(
operation_id="execute",
summary=EXECUTE_SUMMARY,
tags=["deployment"],
auth=DEPLOYMENT_AUTH,
parameters=DEPLOYMENT_PATH_PARAMETERS,
Expand All @@ -218,6 +227,7 @@ class ErrorResponse(serializers.Serializer):
),
get=extend_schema(
operation_id="status",
summary=STATUS_SUMMARY,
tags=["deployment"],
auth=DEPLOYMENT_AUTH,
parameters=DEPLOYMENT_PATH_PARAMETERS + [ExecutionQuerySerializer],
Expand All @@ -242,3 +252,98 @@ class ErrorResponse(serializers.Serializer):
description=STATUS_DESCRIPTION,
),
)


# Declares no field of its own, so a change to the real serializer moves the
# spec. It carries a caller-facing description and a component name the
# pagination envelope can be wrapped round without stuttering.
@extend_schema_serializer(component_name="APIDeploymentSummary")
class APIDeploymentSummary(APIDeploymentListSerializer):
"""One API deployment, as it appears in an organisation's listing.
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.

`api_name` and `api_endpoint` are what an execution call needs;
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
`display_name` and `description` say what the deployment is for.
"""

# The model defaults these, so DRF reports them optional --- true of a
# request body, wrong for a response the server always fills. Left alone,
# a generated client types them nullable and every caller writes a check
# for a key that is always there. The lengths are restated because DRF drops
# them from a read-only field.
api_name = serializers.CharField(read_only=True, max_length=API_NAME_MAX_LENGTH)
display_name = serializers.CharField(read_only=True, max_length=API_NAME_MAX_LENGTH)
description = serializers.CharField(read_only=True, max_length=DESCRIPTION_MAX_LENGTH)
is_active = serializers.BooleanField(read_only=True)


# This operation takes a platform key rather than a deployment key, so its
# credential failures come from the auth middleware in `PlatformKeyError` shape
# rather than from the project exception handler.
API_DEPLOYMENT_LIST_QUERY_PARAMETERS = [
OpenApiParameter(
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
"workflow",
{"type": "string", "format": "uuid"},
OpenApiParameter.QUERY,
description="Return only the deployments of this workflow.",
),
OpenApiParameter(
"api_name",
{"type": "string"},
OpenApiParameter.QUERY,
description="Return only the deployment with exactly this `api_name`.",
),
OpenApiParameter(
"search",
{"type": "string"},
OpenApiParameter.QUERY,
description="Return only deployments whose display name contains this "
"text, case-insensitively.",
),
]

LIST_API_DEPLOYMENTS_SUMMARY = "List an organisation's API deployments"

LIST_API_DEPLOYMENTS_DESCRIPTION = (
"Lists what an organisation has deployed, authenticated by a platform API "
"key that belongs to it.\n\n"
"A deployment key is not part of this listing; executing a deployment "
"still needs one.\n\n"
"Ordered by most recent run first, then by identifier, so paging is stable."
)


API_DEPLOYMENT_LIST_SCHEMA = extend_schema_view(
list=extend_schema(
operation_id="list_deployments",
summary=LIST_API_DEPLOYMENTS_SUMMARY,
tags=["deployment"],
auth=[{"platformKey": []}],
parameters=API_DEPLOYMENT_LIST_QUERY_PARAMETERS,
responses={
200: OpenApiResponse(
APIDeploymentSummary(many=True),
Comment thread
chandrasekharan-zipstack marked this conversation as resolved.
description="One page of the organisation's API deployments.",
),
400: OpenApiResponse(
ErrorResponse,
description="A query parameter was malformed.",
),
401: OpenApiResponse(
PlatformKeyError,
description="No usable platform API key was supplied — absent, "
"malformed, unknown, or revoked.",
),
403: OpenApiResponse(
PlatformKeyError,
description="The key was recognised but refused: it does not "
"belong to the organisation named in the path, or its "
"permission tier is not one this deployment knows.",
),
500: OpenApiResponse(
description="The request could not be served. The body is not "
"guaranteed to be JSON.",
),
},
description=LIST_API_DEPLOYMENTS_DESCRIPTION,
),
)
Loading
Loading