mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user