refactor(openapi): drop the workspace switch op and the console-only current field

difyctl keeps its workspace pin on the device and no longer calls the
server switch, so the op had no caller. `current` on the workspace list
and detail reported the web console's workspace, which read as the one
difyctl uses.
This commit is contained in:
GareArc
2026-10-14 19:42:47 -07:00
parent 223859af9a
commit e7d6485641
12 changed files with 5 additions and 233 deletions
-2
View File
@@ -204,7 +204,6 @@ class WorkspaceSummaryResponse(BaseModel):
name: str
role: str
status: str
current: bool
class WorkspaceListResponse(PaginationEnvelope[WorkspaceSummaryResponse]):
@@ -222,7 +221,6 @@ class WorkspaceDetailResponse(BaseModel):
name: str
role: str
status: str
current: bool
created_at: str | None = None
+1 -33
View File
@@ -57,7 +57,6 @@ from services.errors.workspace import (
InvalidWorkspaceMemberRoleError,
MemberNotInTenantError,
RoleAlreadyAssignedError,
WorkspaceNotLinkedError,
)
from services.workspace.contracts import WorkspaceInvitation, WorkspaceMemberRecord, WorkspaceSnapshot
@@ -87,9 +86,7 @@ class WorkspacesApi(Resource):
def get(self, ctx: Context, *, query: WorkspaceListQuery):
if isinstance(ctx.subject, ResourceAccessSubject):
tenant = ctx.workspace
item = WorkspaceSummaryResponse(
id=str(tenant.id), name=tenant.name, role="", status=tenant.status, current=True
)
item = WorkspaceSummaryResponse(id=str(tenant.id), name=tenant.name, role="", status=tenant.status)
return WorkspaceListResponse.page_of([item], query=query)
rows = application_services().workspaces.management.list_memberships(str(ctx.subject.account_id))
return WorkspaceListResponse.page_of([_workspace_summary(row) for row in rows], query=query)
@@ -116,33 +113,6 @@ class WorkspaceByIdApi(Resource):
return _workspace_detail(row)
@openapi_ns.route("/workspaces/<string:workspace_id>:switch")
class WorkspaceSwitchApi(Resource):
"""Server-side switch — equivalent to the console's POST /workspaces/switch."""
@endpoint(
op="switch.workspace",
kind=Kind.OBJECT,
summary="Server-side current workspace switch (shared with the web console)",
examples=(Example(title="Make a workspace current on the server", input={"workspace_id": "<workspace_id>"}),),
internal=True,
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
),
returns=(200, WorkspaceDetailResponse, "Workspace detail"),
)
def post(self, ctx: Context, workspace_id: str):
try:
workspace = application_services().workspaces.management.switch_membership(
str(ctx.subject.account_id), workspace_id
)
except WorkspaceNotLinkedError:
raise NotFound("workspace not found") from None
return _workspace_detail(workspace)
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
class WorkspaceMembersApi(Resource):
"""List + invite members.
@@ -320,7 +290,6 @@ def _workspace_summary(tenant: WorkspaceSnapshot) -> WorkspaceSummaryResponse:
name=tenant.name,
role=tenant.role or "",
status=tenant.status,
current=tenant.current,
)
@@ -330,6 +299,5 @@ def _workspace_detail(tenant: WorkspaceSnapshot) -> WorkspaceDetailResponse:
name=tenant.name,
role=tenant.role or "",
status=tenant.status,
current=tenant.current,
created_at=tenant.created_at.isoformat() if tenant.created_at else None,
)
-16
View File
@@ -1823,20 +1823,6 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /workspaces/{workspace_id}:switch
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | **application/json**: [WorkspaceDetailResponse](#workspacedetailresponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
---
### Schemas
@@ -3605,7 +3591,6 @@ Page of published versions, newest first; there is no total, `hints` carries the
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | string | | No |
| current | boolean | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| role | string | | Yes |
@@ -3643,7 +3628,6 @@ Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current | boolean | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| role | string | | Yes |
-3
View File
@@ -78,9 +78,6 @@ class WorkspaceService:
def find_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot | None:
return self._workspaces.find_membership(account_id, workspace_id)
def switch_membership(self, account_id: str, workspace_id: str) -> WorkspaceSnapshot:
return self._workspaces.switch(account_id=account_id, workspace_id=workspace_id)
def iter_member_account_id_batches(self, workspace_id: str, batch_size: int) -> Iterator[tuple[str, ...]]:
if batch_size <= 0:
raise ValueError("batch_size must be positive")
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from controllers.openapi._models import WorkspaceListQuery
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi, WorkspaceSwitchApi
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi
from models import Account
from models.account import TenantAccountRole
from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, context_for
@@ -33,9 +33,6 @@ class TestWorkspacesList:
only = result.data[0]
assert only.role == TenantAccountRole.OWNER.value
assert only.status == "normal"
# Newly-created owner membership is not yet "current"; switching flips it
# (see TestWorkspaceSwitch).
assert only.current is False
def test_lists_all_joined_workspaces(
self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account]
@@ -72,7 +69,6 @@ class TestWorkspaceDetail:
assert detail.id == tenant.id
assert detail.role == TenantAccountRole.OWNER.value
assert detail.current is False
assert detail.created_at is not None
def test_non_member_detail_is_404_not_403(
@@ -97,56 +93,3 @@ class TestWorkspaceDetail:
),
someone_elses_ws.id,
)
class TestWorkspaceSwitch:
def test_switch_sets_current_and_persists(
self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account]
) -> None:
account = make_account()
owner_tenant = account.current_tenant
assert owner_tenant is not None
target = add_tenant_for_account(
account, session=db_session_with_containers, role="normal", name="Switch Target"
)
api = WorkspaceSwitchApi()
with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"):
detail = api.post.__handler__(
api,
context_for(account, session=db_session_with_containers, view_args={"workspace_id": target.id}),
target.id,
)
# Response reflects the post-switch state.
assert detail.id == target.id
assert detail.current is True
# And the switch persisted: the previously-current owner workspace is no
# longer current (verified through the real read path).
listing_api = WorkspacesApi()
with app.test_request_context("/openapi/v1/workspaces"):
listing = listing_api.get.__handler__(
listing_api, context_for(account, session=db_session_with_containers), query=WorkspaceListQuery()
)
by_id = {w.id: w for w in listing.data}
assert by_id[target.id].current is True
assert by_id[owner_tenant.id].current is False
def test_switch_to_non_member_workspace_is_404(
self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account]
) -> None:
account = make_account()
outsider_ws = make_account().current_tenant
assert outsider_ws is not None
api = WorkspaceSwitchApi()
with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}:switch", method="POST"):
with pytest.raises(NotFound):
api.post.__handler__(
api,
context_for(
account, session=db_session_with_containers, view_args={"workspace_id": outsider_ws.id}
),
outsider_ws.id,
)
@@ -233,19 +233,17 @@ def test_workspace_list_returns_only_token_tenant(resource_fixture: ResourceFixt
"name": "Token workspace",
"role": "",
"status": "normal",
"current": True,
}
@pytest.mark.parametrize("endpoint", ["detail", "members", "switch"])
@pytest.mark.parametrize("endpoint", ["detail", "members"])
def test_other_workspace_endpoints_reject_resource_token(resource_fixture: ResourceFixture, endpoint: str) -> None:
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspaceMembersApi, WorkspaceSwitchApi
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspaceMembersApi
flask_app, _, _, tenant_id, _ = resource_fixture
methods = {
"detail": WorkspaceByIdApi().get,
"members": WorkspaceMembersApi().get,
"switch": WorkspaceSwitchApi().post,
}
with flask_app.test_request_context(
"/workspaces", headers={"Authorization": "Bearer sk-test", CATALOG_HEADER: catalog_for(flask_app)[1]}
@@ -269,7 +269,6 @@ ROUTES: tuple[Route, ...] = (
Route("apps.list", "GET", "/apps", frozenset({Trait.ACCOUNT_PRIMARY}), query="workspace_id={workspace_id}"),
Route("workspaces.list", "GET", "/workspaces", frozenset({Trait.ACCOUNT_PRIMARY})),
Route("workspaces.describe", "GET", "/workspaces/{workspace_id}", frozenset({Trait.ACCOUNT_PRIMARY})),
Route("workspaces.switch", "POST", "/workspaces/{workspace_id}:switch", frozenset({Trait.ACCOUNT_PRIMARY})),
Route("workspaces.members.list", "GET", "/workspaces/{workspace_id}/members", frozenset({Trait.ACCOUNT_PRIMARY})),
Route(
"workspaces.members.invite",
@@ -2070,7 +2069,6 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = {
("post", "/workspaces/{workspace_id}/members"): frozenset({"201", "422", "default"}),
("delete", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "default"}),
("patch", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "422", "default"}),
("post", "/workspaces/{workspace_id}:switch"): frozenset({"200", "default"}),
("get", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "default"}),
("patch", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "422", "default"}),
("post", "/apps/{app_id}/webapp:reset"): frozenset({"200", "default"}),
@@ -117,7 +117,6 @@ def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -
assert ops["upload.console_app.file"]["bind"]["file"] == "file"
assert ops["get.console_app"]["bind"]["page"] == "query"
assert ops["get.run.event"]["bind"]["continue_on_pause"] == "query"
assert ops["switch.workspace"]["internal"] is True
def test_input_schemas_are_flat_shallow_and_described(ops: dict[str, CatalogOp]) -> None:
@@ -45,7 +45,7 @@ from controllers.openapi._models import MemberInvitePayload, MemberListQuery, Me
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.loaders import load_caller, load_workspace
from controllers.openapi.auth.subjects import AccountSubject
from controllers.openapi.workspaces import WorkspaceMemberApi, WorkspaceMembersApi, WorkspaceSwitchApi
from controllers.openapi.workspaces import WorkspaceMemberApi, WorkspaceMembersApi
from libs.oauth_bearer import AuthContext
from models import Account, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
@@ -59,7 +59,6 @@ from services.errors.workspace import (
RoleAlreadyAssignedError,
WorkspaceInvitationQuotaError,
WorkspaceMemberLicenseQuotaError,
WorkspaceNotLinkedError,
)
from tests.unit_tests.account_domain import AccountDomain
from tests.unit_tests.controllers.openapi.conftest import AdmittedWorld
@@ -221,64 +220,6 @@ def test_invalid_request_is_422_on_the_wire(
_assert_validation_422(resp)
# ---------------------------------------------------------------------------
# Switch endpoint behavior
# ---------------------------------------------------------------------------
def test_switch_returns_workspace_detail_with_current_true(database_session: Session):
"""Happy path: switch service is called, then the workspace+membership
row is re-queried so the returned `current` reflects post-commit state.
"""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceSwitchApi()
_persist_workspace(
database_session,
ws_id,
[(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, False)],
)
result = api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id)
assert result.id == ws_id
assert result.current is True
membership = database_session.scalar(
select(TenantAccountJoin).where(
TenantAccountJoin.tenant_id == ws_id,
TenantAccountJoin.account_id == str(acct_id),
)
)
assert membership is not None
assert membership.current is True
def test_switch_404s_when_service_raises_account_not_link_tenant(
monkeypatch: pytest.MonkeyPatch, database_session: Session
):
"""If switch_tenant raises (e.g. Tenant.status != NORMAL), the body
surfaces as NotFound, not 500."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceSwitchApi()
_persist_workspace(
database_session,
ws_id,
[(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, False)],
)
monkeypatch.setattr(
workspaces_module.application_services().workspaces.management,
"switch_membership",
Mock(side_effect=WorkspaceNotLinkedError("…")),
)
with pytest.raises(NotFound):
api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id)
# ---------------------------------------------------------------------------
# Members list
# ---------------------------------------------------------------------------
@@ -246,8 +246,6 @@ import {
zPostWorkspacesByWorkspaceIdPluginsInstallBody,
zPostWorkspacesByWorkspaceIdPluginsInstallPath,
zPostWorkspacesByWorkspaceIdPluginsInstallResponse,
zPostWorkspacesByWorkspaceIdSwitchPath,
zPostWorkspacesByWorkspaceIdSwitchResponse,
zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsBody,
zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsPath,
zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponse,
@@ -2137,21 +2135,6 @@ export const tools = {
get: get47,
}
export const post30 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdSwitch',
path: '/workspaces/{workspace_id}:switch',
tags: ['openapi'],
})
.input(z.object({ params: zPostWorkspacesByWorkspaceIdSwitchPath }))
.output(zPostWorkspacesByWorkspaceIdSwitchResponse)
export const switch_ = {
post: post30,
}
export const get48 = oc
.route({
inputStructure: 'detailed',
@@ -2175,7 +2158,6 @@ export const byWorkspaceId = {
plugins: plugins2,
toolProviders,
tools,
switch: switch_,
}
export const get49 = oc
@@ -1463,7 +1463,6 @@ export type WorkflowWebAppPatch = {
export type WorkspaceDetailResponse = {
created_at?: string | null
current: boolean
id: string
name: string
role: string
@@ -1491,7 +1490,6 @@ export type WorkspacePayload = {
}
export type WorkspaceSummaryResponse = {
current: boolean
id: string
name: string
role: string
@@ -4002,26 +4000,3 @@ export type GetWorkspacesByWorkspaceIdToolsResponses = {
export type GetWorkspacesByWorkspaceIdToolsResponse =
GetWorkspacesByWorkspaceIdToolsResponses[keyof GetWorkspacesByWorkspaceIdToolsResponses]
export type PostWorkspacesByWorkspaceIdSwitchData = {
body?: never
path: {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}:switch'
}
export type PostWorkspacesByWorkspaceIdSwitchErrors = {
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdSwitchError =
PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors]
export type PostWorkspacesByWorkspaceIdSwitchResponses = {
200: WorkspaceDetailResponse
}
export type PostWorkspacesByWorkspaceIdSwitchResponse =
PostWorkspacesByWorkspaceIdSwitchResponses[keyof PostWorkspacesByWorkspaceIdSwitchResponses]
@@ -2056,7 +2056,6 @@ export const zWorkflowWebAppPatch = z.object({
*/
export const zWorkspaceDetailResponse = z.object({
created_at: z.string().nullish(),
current: z.boolean(),
id: z.string(),
name: z.string(),
role: z.string(),
@@ -2113,7 +2112,6 @@ export const zDeviceTokenResponse = z.object({
* WorkspaceSummaryResponse
*/
export const zWorkspaceSummaryResponse = z.object({
current: z.boolean(),
id: z.string(),
name: z.string(),
role: z.string(),
@@ -3247,12 +3245,3 @@ export const zGetWorkspacesByWorkspaceIdToolsQuery = z.object({
* Tools
*/
export const zGetWorkspacesByWorkspaceIdToolsResponse = zToolListResponse
export const zPostWorkspacesByWorkspaceIdSwitchPath = z.object({
workspace_id: z.string(),
})
/**
* Workspace detail
*/
export const zPostWorkspacesByWorkspaceIdSwitchResponse = zWorkspaceDetailResponse