From 6ca8b7bd6e5dfbbd68152dac4e19ac9506af9b9b Mon Sep 17 00:00:00 2001 From: L1nSn0w Date: Fri, 3 Jul 2026 13:35:53 +0800 Subject: [PATCH] refactor(openapi): adopt resource-oriented paths and colon custom methods Align the /openapi/v1 surface with the API Design Guide: replace verb path suffixes with standard resource GETs and AIP-136 `:action` custom methods. GET /apps/{id}/describe -> GET /apps/{id} GET /permitted-external-apps/{id}/describe -> GET /permitted-external-apps/{id} POST /apps/{id}/run -> POST /apps/{id}:run POST /apps/{id}/tasks/{tid}/stop -> POST /apps/{id}/tasks/{tid}:stop POST /apps/{id}/files/upload -> POST /apps/{id}/files GET /apps/{id}/export -> GET /apps/{id}/dsl GET /apps/{id}/check-dependencies -> GET /apps/{id}/dependencies POST /workspaces/{ws}/apps/imports/{iid}/confirm -> POST /workspaces/{ws}/apps/imports/{iid}:confirm POST /workspaces/{ws}/switch -> POST /workspaces/{ws}:switch GET|POST /apps/{id}/form/human_input/{tok} -> /apps/{id}/human-input-forms/{tok} (+ :submit for POST) PUT /workspaces/{ws}/members/{mid}/role -> PATCH /workspaces/{ws}/members/{mid} difyctl is the sole consumer and ships in lockstep, so this is a hard cutover (no /v2, no dual-mount). RFC 8628 device-flow routes and the `limit` pagination param are intentionally left unchanged. --- api/controllers/openapi/_models.py | 8 ++-- api/controllers/openapi/app_dsl.py | 8 ++-- api/controllers/openapi/app_run.py | 6 +-- api/controllers/openapi/apps.py | 2 +- .../openapi/apps_permitted_external.py | 2 +- api/controllers/openapi/files.py | 4 +- api/controllers/openapi/human_input_form.py | 9 ++-- api/controllers/openapi/workspaces.py | 18 +++----- .../controllers/openapi/test_app_run.py | 24 +++++------ .../controllers/openapi/test_apps.py | 16 +++---- .../controllers/openapi/test_app_dsl.py | 8 ++-- .../controllers/openapi/test_app_run.py | 2 +- .../controllers/openapi/test_apps.py | 6 +-- .../controllers/openapi/test_files.py | 2 +- .../controllers/openapi/test_workspaces.py | 4 +- .../openapi/test_app_run_streaming.py | 8 ++-- .../openapi/test_human_input_form.py | 32 +++++++------- .../openapi/test_workspaces_members.py | 42 +++++++++---------- 18 files changed, 96 insertions(+), 105 deletions(-) diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 6e8a9c9d439..5337612e7b6 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -279,7 +279,7 @@ def _csv_string_query_schema(schema: dict[str, Any]) -> None: class AppDescribeQuery(BaseModel): - """`?fields=` allow-list for GET /apps//describe. + """`?fields=` allow-list for GET /apps/. Empty / omitted → all blocks. Unknown member → ValidationError → 422. """ @@ -441,7 +441,7 @@ class MemberActionResponse(BaseModel): class TaskStopResponse(BaseModel): - """200 body for POST /apps//tasks//stop. The handler always returns + """200 body for POST /apps//tasks/:stop. The handler always returns {"result": "success"}, so `result` is required (no default) — the generated contract types it as a required `'success'` rather than an optional field.""" @@ -473,7 +473,7 @@ class AppDslImportPayload(BaseModel): class AppDslExportQuery(BaseModel): - """Query parameters for GET /apps//export.""" + """Query parameters for GET /apps//dsl.""" include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL") workflow_id: UUIDStr | None = Field( @@ -488,7 +488,7 @@ class AppDslExportResponse(BaseModel): class FormSubmitResponse(BaseModel): - """Empty 200 body for POST /apps//form/human_input/. `extra='forbid'` + """Empty 200 body for POST /apps//human-input-forms/:submit. `extra='forbid'` pins `additionalProperties: false` so the generated contract is an exact `{}` rather than an under-annotated open object.""" diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index 9b1abd24bac..35916d61e41 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -30,7 +30,7 @@ class AppDslImportApi(Resource): a new app. Returns 202 when the DSL version requires an explicit confirmation step - (major version mismatch). Callers must then POST to the confirm endpoint. + (major version mismatch). Callers must then POST to the imports :confirm method. Returns 400 when the import failed due to invalid DSL or a business error. """ @@ -79,7 +79,7 @@ class AppDslImportApi(Resource): return result, 200 -@openapi_ns.route("/workspaces//apps/imports//confirm") +@openapi_ns.route("/workspaces//apps/imports/:confirm") class AppDslImportConfirmApi(Resource): """Confirm a pending DSL import identified by ``import_id``. @@ -119,7 +119,7 @@ class AppDslImportConfirmApi(Resource): return result, 200 -@openapi_ns.route("/apps//export") +@openapi_ns.route("/apps//dsl") class AppDslExportApi(Resource): """Export an app's current draft configuration as a DSL YAML string. @@ -153,7 +153,7 @@ class AppDslExportApi(Resource): return AppDslExportResponse(data=data), 200 -@openapi_ns.route("/apps//check-dependencies") +@openapi_ns.route("/apps//dependencies") class AppDslCheckDependenciesApi(Resource): """Check for leaked plugin dependencies after a DSL import. diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index 6074c7c0e02..772513ad417 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -1,4 +1,4 @@ -"""POST /openapi/v1/apps//run — mode-agnostic runner.""" +"""POST /openapi/v1/apps/:run — mode-agnostic runner.""" from __future__ import annotations @@ -138,7 +138,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = { } -@openapi_ns.route("/apps//run") +@openapi_ns.route("/apps/:run") class AppRunApi(Resource): @auth_router.guard( scope=Scope.APPS_RUN, @@ -174,7 +174,7 @@ class AppRunApi(Resource): return helper.compact_generate_response(stream_obj) -@openapi_ns.route("/apps//tasks//stop") +@openapi_ns.route("/apps//tasks/:stop") class AppRunTaskStopApi(Resource): @auth_router.guard( scope=Scope.APPS_RUN, diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 8d5c9670e77..d4cb175ba5e 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -129,7 +129,7 @@ def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescrib return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema) -@openapi_ns.route("/apps//describe") +@openapi_ns.route("/apps/") class AppDescribeApi(AppReadResource): @auth_router.guard( scope=Scope.APPS_READ, diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index 718d3dbd169..5c6fdce5141 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -87,7 +87,7 @@ class PermittedExternalAppsListApi(Resource): return env -@openapi_ns.route("/permitted-external-apps//describe") +@openapi_ns.route("/permitted-external-apps/") class PermittedExternalAppDescribeApi(Resource): @auth_router.guard( scope=Scope.APPS_READ_PERMITTED_EXTERNAL, diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py index 7326a4a922e..3b3f68fa36b 100644 --- a/api/controllers/openapi/files.py +++ b/api/controllers/openapi/files.py @@ -1,4 +1,4 @@ -"""POST /openapi/v1/apps//files/upload — upload a file for use in app inputs.""" +"""POST /openapi/v1/apps//files — upload a file for use in app inputs.""" from __future__ import annotations @@ -26,7 +26,7 @@ from libs.oauth_bearer import Scope from services.file_service import FileService -@openapi_ns.route("/apps//files/upload") +@openapi_ns.route("/apps//files") class AppFileUploadApi(Resource): @openapi_ns.doc("upload_file_for_app_input") @openapi_ns.doc(description="Upload a file to use as an input variable when running the app") diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index 998dd669836..593887ffcfc 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -1,8 +1,8 @@ """ OpenAPI bearer-authed human input form endpoints. -GET /apps//form/human_input/ — fetch paused form definition -POST /apps//form/human_input/ — submit form response +GET /apps//human-input-forms/ — fetch paused form definition +POST /apps//human-input-forms/:submit — submit form response """ from __future__ import annotations @@ -60,7 +60,7 @@ def _ensure_form_is_allowed_for_openapi(form) -> None: raise RecipientSurfaceMismatch() -@openapi_ns.route("/apps//form/human_input/") +@openapi_ns.route("/apps//human-input-forms/") class OpenApiWorkflowHumanInputFormApi(Resource): @openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__]) @auth_router.guard( @@ -79,6 +79,9 @@ class OpenApiWorkflowHumanInputFormApi(Resource): service.ensure_form_active(form) return _jsonify_form_definition(form) + +@openapi_ns.route("/apps//human-input-forms/:submit") +class OpenApiWorkflowHumanInputFormSubmitApi(Resource): @auth_router.guard( scope=Scope.APPS_RUN, rbac=RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN), diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 49f8fb9656f..c45c02e54d3 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -113,7 +113,7 @@ class WorkspaceByIdApi(Resource): return _workspace_detail(tenant, membership) -@openapi_ns.route("/workspaces//switch") +@openapi_ns.route("/workspaces/:switch") class WorkspaceSwitchApi(Resource): """Server-side switch — equivalent to the console's POST /workspaces/switch. @@ -212,11 +212,12 @@ class WorkspaceMembersApi(Resource): @openapi_ns.route("/workspaces//members/") class WorkspaceMemberApi(Resource): - """Remove a member. + """Remove a member (DELETE) or change a member's role (PATCH). Self-removal and owner-removal are explicitly rejected by the service layer (CannotOperateSelfError, NoPermissionError) — both surface as - 400 per the spec, with the service's message preserved. + 400 per the spec, with the service's message preserved. Owner can never be + assigned via PATCH (closed enum); admin cannot demote the standing owner. """ @auth_router.guard_workspace( @@ -243,15 +244,6 @@ class WorkspaceMemberApi(Resource): return MemberActionResponse() - -@openapi_ns.route("/workspaces//members//role") -class WorkspaceMemberRoleApi(Resource): - """Change a member's role. - - Owner cannot be assigned here (closed enum). Admin cannot demote the - standing owner (service NoPermissionError → 400, per spec). - """ - @auth_router.guard_workspace( scope=Scope.WORKSPACE_WRITE, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), @@ -259,7 +251,7 @@ class WorkspaceMemberRoleApi(Resource): ) @returns(200, MemberActionResponse, description="Role updated") @accepts(body=MemberRoleUpdatePayload) - def put(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload): + def patch(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload): operator = _load_account(auth_data.account_id) tenant = _load_tenant(workspace_id) member = AccountService.get_account_by_id(db.session, member_id) diff --git a/api/tests/integration_tests/controllers/openapi/test_app_run.py b/api/tests/integration_tests/controllers/openapi/test_app_run.py index b4f383a7cea..fbfedd0f269 100644 --- a/api/tests/integration_tests/controllers/openapi/test_app_run.py +++ b/api/tests/integration_tests/controllers/openapi/test_app_run.py @@ -1,4 +1,4 @@ -"""Integration tests for POST /openapi/v1/apps//run.""" +"""Integration tests for POST /openapi/v1/apps/:run.""" from __future__ import annotations @@ -36,7 +36,7 @@ def test_run_chat_dispatches_to_chat_handler( monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)) client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}/run", + f"/openapi/v1/apps/{app_in_workspace.id}:run", json={"inputs": {}, "query": "hi", "response_mode": "blocking", "user": "spoof@x.com"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -85,7 +85,7 @@ def test_run_chat_without_query_returns_422( ): client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}/run", + f"/openapi/v1/apps/{app_in_workspace.id}:run", json={"inputs": {}, "response_mode": "blocking"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -116,7 +116,7 @@ def test_run_completion_dispatches_to_completion_handler( monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)) client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app.id}/run", + f"/openapi/v1/apps/{app.id}:run", json={"inputs": {}, "response_mode": "blocking"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -131,7 +131,7 @@ def test_run_workflow_with_query_returns_422( app = app_with_mode("workflow") client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app.id}/run", + f"/openapi/v1/apps/{app.id}:run", json={"inputs": {}, "query": "hi", "response_mode": "blocking"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -154,7 +154,7 @@ def test_run_workflow_no_query_dispatches_to_workflow_handler( monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)) client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app.id}/run", + f"/openapi/v1/apps/{app.id}:run", json={"inputs": {}, "response_mode": "blocking"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -170,7 +170,7 @@ def test_run_unsupported_mode_returns_422( app = app_with_mode("channel") client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app.id}/run", + f"/openapi/v1/apps/{app.id}:run", json={"inputs": {}, "response_mode": "blocking"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -181,7 +181,7 @@ def test_run_unsupported_mode_returns_422( def test_run_without_bearer_returns_401(flask_app: Flask, app_in_workspace): client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}/run", + f"/openapi/v1/apps/{app_in_workspace.id}:run", json={"inputs": {}, "query": "hi"}, ) assert res.status_code == 401 @@ -205,7 +205,7 @@ def test_run_with_insufficient_scope_returns_403( client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}/run", + f"/openapi/v1/apps/{app_in_workspace.id}:run", json={"inputs": {}, "query": "hi"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -215,7 +215,7 @@ def test_run_with_insufficient_scope_returns_403( def test_run_with_unknown_app_returns_404(flask_app: Flask, account_token): client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{uuid.uuid4()}/run", + f"/openapi/v1/apps/{uuid.uuid4()}:run", json={"inputs": {}, "query": "hi"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -235,7 +235,7 @@ def test_run_streaming_returns_event_stream( client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}/run", + f"/openapi/v1/apps/{app_in_workspace.id}:run", json={"inputs": {}, "query": "hi", "response_mode": "streaming"}, headers={"Authorization": f"Bearer {account_token}"}, ) @@ -247,7 +247,7 @@ def test_run_streaming_returns_event_stream( def test_run_without_inputs_returns_422(flask_app: Flask, account_token, app_in_workspace): client = flask_app.test_client() res = client.post( - f"/openapi/v1/apps/{app_in_workspace.id}/run", + f"/openapi/v1/apps/{app_in_workspace.id}:run", json={"query": "hi"}, headers={"Authorization": f"Bearer {account_token}"}, ) diff --git a/api/tests/integration_tests/controllers/openapi/test_apps.py b/api/tests/integration_tests/controllers/openapi/test_apps.py index 20ac46fbbde..ab992d1c5eb 100644 --- a/api/tests/integration_tests/controllers/openapi/test_apps.py +++ b/api/tests/integration_tests/controllers/openapi/test_apps.py @@ -37,7 +37,7 @@ def test_apps_describe_returns_merged_shape( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe", + f"/openapi/v1/apps/{app_in_workspace.id}", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 200 @@ -53,7 +53,7 @@ def test_apps_describe_full_includes_input_schema( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe", + f"/openapi/v1/apps/{app_in_workspace.id}", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 200 @@ -70,7 +70,7 @@ def test_apps_describe_fields_info_only( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info", + f"/openapi/v1/apps/{app_in_workspace.id}?fields=info", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 200 @@ -86,7 +86,7 @@ def test_apps_describe_fields_parameters_only( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=parameters", + f"/openapi/v1/apps/{app_in_workspace.id}?fields=parameters", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 200 @@ -102,7 +102,7 @@ def test_apps_describe_fields_input_schema_only( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=input_schema", + f"/openapi/v1/apps/{app_in_workspace.id}?fields=input_schema", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 200 @@ -118,7 +118,7 @@ def test_apps_describe_fields_combined( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info,input_schema", + f"/openapi/v1/apps/{app_in_workspace.id}?fields=info,input_schema", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 200 @@ -134,7 +134,7 @@ def test_apps_describe_fields_unknown_returns_422( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=garbage", + f"/openapi/v1/apps/{app_in_workspace.id}?fields=garbage", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 422 @@ -146,7 +146,7 @@ def test_apps_describe_fields_extra_param_returns_422( account_token: str, ): res = test_client.get( - f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info&page=1", + f"/openapi/v1/apps/{app_in_workspace.id}?fields=info&page=1", headers={"Authorization": f"Bearer {account_token}"}, ) assert res.status_code == 422 diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py index 93e8927cfef..2dee78fb7c4 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py @@ -167,7 +167,7 @@ class TestDslImportConfirm: api = AppDslImportConfirmApi() with app.test_request_context( - f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}/confirm", method="POST" + f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}:confirm", method="POST" ): result, code = unwrap(api.post)( api, workspace_id=tenant.id, import_id=import_id, auth_data=auth_for(account) @@ -198,7 +198,7 @@ class TestDslExport: db_session_with_containers.commit() api = AppDslExportApi() - with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"): + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"): response, code = unwrap(api.get)( api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() ) @@ -216,7 +216,7 @@ class TestDslExport: app_model, account = _app_and_account(db_session_with_containers, mode="workflow") api = AppDslExportApi() - with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/export"): + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"): result, code = unwrap(api.get)( api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() ) @@ -232,7 +232,7 @@ class TestDslCheckDependencies: app_model, account = _app_and_account(db_session_with_containers, mode="chat") api = AppDslCheckDependenciesApi() - with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/check-dependencies"): + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dependencies"): result, code = unwrap(api.get)(api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model)) assert code == 200 diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py index c6fde623677..8e9278ad244 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py @@ -38,7 +38,7 @@ class TestAppRunTaskStop: task_id = str(uuid4()) api = AppRunTaskStopApi() - with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}/stop", method="POST"): + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}:stop", method="POST"): result = unwrap(api.post)( api, app_id=app_model.id, diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py index 22f812e125b..24580ae0a0e 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py @@ -120,7 +120,7 @@ class TestAppDescribe: app_model = _create_app(db_session_with_containers, account, name="Describe Me", enable_api=True) api = AppDescribeApi() - with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/describe?fields=info"): + with app.test_request_context(f"/openapi/v1/apps/{app_model.id}?fields=info"): result = unwrap(api.get)( api, app_id=app_model.id, auth_data=auth_for(account), query=AppDescribeQuery(fields="info") ) @@ -138,7 +138,7 @@ class TestAppDescribe: missing_id = str(uuid4()) api = AppDescribeApi() - with app.test_request_context(f"/openapi/v1/apps/{missing_id}/describe"): + with app.test_request_context(f"/openapi/v1/apps/{missing_id}"): with pytest.raises(NotFound): unwrap(api.get)(api, app_id=missing_id, auth_data=auth_for(account), query=AppDescribeQuery()) @@ -151,6 +151,6 @@ class TestAppDescribe: hidden = _create_app(db_session_with_containers, account, name="Hidden", enable_api=False) api = AppDescribeApi() - with app.test_request_context(f"/openapi/v1/apps/{hidden.id}/describe"): + with app.test_request_context(f"/openapi/v1/apps/{hidden.id}"): with pytest.raises(NotFound): unwrap(api.get)(api, app_id=hidden.id, auth_data=auth_for(account), query=AppDescribeQuery()) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py index b90d5ab907c..86cf70613c9 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py @@ -43,7 +43,7 @@ class TestAppFileUpload: api = AppFileUploadApi() data = {"file": (BytesIO(content), "note.txt", "text/plain")} with app.test_request_context( - f"/openapi/v1/apps/{app_model.id}/files/upload", + f"/openapi/v1/apps/{app_model.id}/files", method="POST", data=data, content_type="multipart/form-data", diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py index 18075704325..5e794ae1982 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py @@ -95,7 +95,7 @@ class TestWorkspaceSwitch: ) api = WorkspaceSwitchApi() - with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"): + with app.test_request_context(f"/openapi/v1/workspaces/{target.id}:switch", method="POST"): detail = unwrap(api.post)(api, workspace_id=target.id, auth_data=auth_for(account)) # Response reflects the post-switch state. @@ -118,6 +118,6 @@ class TestWorkspaceSwitch: assert outsider_ws is not None api = WorkspaceSwitchApi() - with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}/switch", method="POST"): + with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}:switch", method="POST"): with pytest.raises(NotFound): unwrap(api.post)(api, workspace_id=outsider_ws.id, auth_data=auth_for(account)) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index b82ab254d45..7672e6414fd 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -72,7 +72,7 @@ def test_run_chat_always_calls_generate_with_streaming_true( "AppGenerateService", GenerateService, ) - with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"): + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}:run", method="POST"): _run_chat( _make_app(), _make_account(), @@ -84,9 +84,9 @@ def test_run_chat_always_calls_generate_with_streaming_true( def test_stop_task_endpoint_registered(openapi_app): - """POST /openapi/v1/apps//tasks//stop must be registered.""" + """POST /openapi/v1/apps//tasks/:stop must be registered.""" rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/apps//tasks//stop" in rules + assert "/openapi/v1/apps//tasks/:stop" in rules def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): @@ -117,7 +117,7 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel ) api = AppRunTaskStopApi() - with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1/stop", method="POST"): + with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"): result = api.post.__wrapped__( api, app_id="app-1", diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index 5659cd6eeff..c4d3d21d84a 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -62,7 +62,7 @@ class TestOpenApiHumanInputFormGet: app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="acct-1") - with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): + with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"): resp = api.get.__wrapped__( api, app_id="app-1", @@ -89,7 +89,7 @@ class TestOpenApiHumanInputFormGet: app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="acct-1") - with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"): + with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/bad"): with pytest.raises(HumanInputFormNotFound): api.get.__wrapped__( api, @@ -117,7 +117,7 @@ class TestOpenApiHumanInputFormGet: app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="acct-1") - with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): + with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"): with pytest.raises(HumanInputFormNotFound): api.get.__wrapped__( api, @@ -145,7 +145,7 @@ class TestOpenApiHumanInputFormGet: app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="acct-1") - with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): + with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"): with pytest.raises(RecipientSurfaceMismatch): api.get.__wrapped__( api, @@ -165,7 +165,7 @@ class TestOpenApiHumanInputFormPost: ) def test_post_account_caller_uses_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi form = self._make_form() service_mock = Mock() @@ -175,12 +175,12 @@ class TestOpenApiHumanInputFormPost: monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) - api = OpenApiWorkflowHumanInputFormApi() + api = OpenApiWorkflowHumanInputFormSubmitApi() app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="acct-42") with app.test_request_context( - "/openapi/v1/apps/app-1/form/human_input/tok-1", + "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", method="POST", json={"action": "approve", "inputs": {"field1": "val"}}, ): @@ -202,7 +202,7 @@ class TestOpenApiHumanInputFormPost: assert result == ({}, 200) def test_post_end_user_caller_uses_end_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi form = self._make_form() service_mock = Mock() @@ -212,12 +212,12 @@ class TestOpenApiHumanInputFormPost: monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) - api = OpenApiWorkflowHumanInputFormApi() + api = OpenApiWorkflowHumanInputFormSubmitApi() app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="eu-7") with app.test_request_context( - "/openapi/v1/apps/app-1/form/human_input/tok-1", + "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", method="POST", json={"action": "approve", "inputs": {}}, ): @@ -241,7 +241,7 @@ class TestOpenApiHumanInputFormPost: def test_post_standalone_web_app_recipient_submits( self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch ): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP) service_mock = Mock() @@ -251,12 +251,12 @@ class TestOpenApiHumanInputFormPost: monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) - api = OpenApiWorkflowHumanInputFormApi() + api = OpenApiWorkflowHumanInputFormSubmitApi() app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="anyone") with app.test_request_context( - "/openapi/v1/apps/app-1/form/human_input/tok-1", + "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", method="POST", json={"action": "approve", "inputs": {}}, ): @@ -272,14 +272,14 @@ class TestOpenApiHumanInputFormPost: def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline): """Malformed body → 422 via @accepts (was an unmapped pydantic error → 500).""" - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi - api = OpenApiWorkflowHumanInputFormApi() + api = OpenApiWorkflowHumanInputFormSubmitApi() app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") caller = SimpleNamespace(id="acct-42") with app.test_request_context( - "/openapi/v1/apps/app-1/form/human_input/tok-1", + "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", method="POST", json={"inputs": {"field1": "val"}}, # missing required "action" ): diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index cf9fa671987..b78473fadda 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -1,7 +1,7 @@ """Member endpoints under /openapi/v1/workspaces//... Coverage: -- Route registration (5 endpoints across 4 URL patterns) +- Route registration (5 endpoints across 3 URL patterns) - Body validation lands at 400 (per spec — not Pydantic's default 422) - Domain exception → HTTP code mapping is preserved with the service's original message (so CLI users see what the console user sees) @@ -37,7 +37,6 @@ from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePay from controllers.openapi.auth.data import AuthData from controllers.openapi.workspaces import ( WorkspaceMemberApi, - WorkspaceMemberRoleApi, WorkspaceMembersApi, WorkspaceSwitchApi, ) @@ -175,7 +174,7 @@ def _account_service(**overrides) -> SimpleNamespace: def test_switch_route_registered(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/workspaces//switch") + rule = _rule(openapi_app, "/openapi/v1/workspaces/:switch") assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceSwitchApi assert "POST" in rule.methods @@ -191,12 +190,7 @@ def test_member_by_id_route_registered(openapi_app: Flask): rule = _rule(openapi_app, "/openapi/v1/workspaces//members/") assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberApi assert "DELETE" in rule.methods - - -def test_member_role_route_registered(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/workspaces//members//role") - assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberRoleApi - assert "PUT" in rule.methods + assert "PATCH" in rule.methods # --------------------------------------------------------------------------- @@ -250,17 +244,17 @@ def test_update_role_rejects_invalid_body_with_422(app: Flask, bypass_pipeline): """Invalid role-update body surfaces as 422 through @accepts (was 400).""" ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() - api = WorkspaceMemberRoleApi() + api = WorkspaceMemberApi() with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role", - method="PUT", + f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", + method="PATCH", data=json.dumps({"role": "owner"}), # closed enum rejects owner content_type="application/json", ): _seed(_auth_ctx(account_id=acct_id)) with pytest.raises(UnprocessableEntity): - api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)) + api.patch.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)) # --------------------------------------------------------------------------- @@ -291,7 +285,7 @@ def test_switch_returns_workspace_detail_with_current_true( ) monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db) - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"): + with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"): _seed(_auth_ctx(account_id=acct_id)) body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) @@ -320,7 +314,7 @@ def test_switch_404s_when_service_raises_account_not_link_tenant( ) monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db) - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"): + with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"): _seed(_auth_ctx(account_id=acct_id)) with pytest.raises(NotFound): api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) @@ -767,7 +761,7 @@ def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, monk def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() - api = WorkspaceMemberRoleApi() + api = WorkspaceMemberApi() mock_db = MagicMock() mock_db.session.get.side_effect = [ @@ -785,13 +779,15 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db) with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role", - method="PUT", + f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", + method="PATCH", data=json.dumps({"role": "admin"}), content_type="application/json", ): _seed(_auth_ctx(account_id=acct_id)) - body, status = api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)) + body, status = api.patch.__wrapped__( + api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id) + ) assert status == 200 assert body == {"result": "success"} @@ -811,7 +807,7 @@ def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch, exc, expected): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() - api = WorkspaceMemberRoleApi() + api = WorkspaceMemberApi() mock_db = MagicMock() mock_db.session.get.side_effect = [ @@ -828,14 +824,14 @@ def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch, monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db) with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role", - method="PUT", + f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", + method="PATCH", data=json.dumps({"role": "admin"}), content_type="application/json", ): _seed(_auth_ctx(account_id=acct_id)) with pytest.raises(expected): - api.put.__wrapped__( + api.patch.__wrapped__( api, workspace_id=ws_id, member_id=member_id,