refactor(openapi/cli): drop tag/author from app usage-face noun

The app noun is the usage face; tags and author are build/management
metadata that belong to studio-app, not here. Remove them end to end:

- backend: drop tags/created_by_name from AppListRow, tag from
  AppListQuery, the TagItem model, and the tag-name filter lookup;
  stop hardcoding the cross-tenant blanks in the permitted-external list
- cli: remove the --tag flag, TAGS/AUTHOR columns, and tag from the
  list query; single get app <id> no longer fabricates the fields
- regenerate openapi contracts (types/zod) and markdown docs

get app and get app <id> now agree (neither surfaces tags/author),
resolving the list-vs-single divergence raised in review.
This commit is contained in:
GareArc 2026-06-21 21:04:03 -07:00
parent 00995545e7
commit c3f56fcc9a
No known key found for this signature in database
14 changed files with 42 additions and 136 deletions

View File

@ -62,7 +62,6 @@ from controllers.openapi._models import (
SessionListQuery,
SessionListResponse,
SessionRow,
TagItem,
TaskStopResponse,
UsageInfo,
WorkflowRunData,
@ -96,7 +95,6 @@ register_response_schema_models(
openapi_ns,
ErrorBody,
EventStreamResponse,
TagItem,
UsageInfo,
MessageMetadata,
AppListRow,

View File

@ -38,18 +38,12 @@ class PaginationEnvelope[T](BaseModel):
return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items)
class TagItem(BaseModel):
name: str
class AppListRow(BaseModel):
id: str
name: str
description: str | None = None
mode: AppMode
tags: list[TagItem] = []
updated_at: str | None = None
created_by_name: str | None = None
workspace_id: str | None = None
workspace_name: str | None = None
@ -292,7 +286,6 @@ class AppListQuery(BaseModel):
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
mode: AppMode | None = None
name: str | None = Field(None, max_length=200)
tag: str | None = Field(None, max_length=100)
class AppRunRequest(BaseModel):

View File

@ -20,7 +20,6 @@ from controllers.openapi._models import (
AppListQuery,
AppListResponse,
AppListRow,
TagItem,
)
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
@ -32,7 +31,6 @@ from models import App
from models.model import AppMode
from services.account_service import TenantService
from services.app_service import AppListParams, AppService
from services.tag_service import TagService
_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"})
@ -164,28 +162,18 @@ class AppListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[TagItem(name=t.name) for t in app.tags],
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=getattr(app, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)
env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item])
return env
tag_ids: list[str] | None = None
if query.tag:
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session)
if not tags:
return empty
tag_ids = [tag.id for tag in tags]
params = AppListParams(
page=query.page,
limit=query.limit,
mode=query.mode.value if query.mode else "all", # type:ignore
name=query.name,
tag_ids=tag_ids,
status="normal",
# Visibility gate pushed into the query — pagination.total stays
# consistent across pages because invisible rows never count.
@ -206,9 +194,7 @@ class AppListApi(Resource):
name=r.name,
description=r.description,
mode=r.mode,
tags=[TagItem(name=t.name) for t in r.tags],
updated_at=r.updated_at.isoformat() if r.updated_at else None,
created_by_name=getattr(r, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)

View File

@ -71,9 +71,7 @@ class PermittedExternalAppsListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[], # tenant-scoped; not surfaced cross-tenant
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=None, # cross-tenant author leak prevention
workspace_id=str(app.tenant_id),
workspace_name=tenant.name if tenant else None,
)

View File

@ -83,7 +83,6 @@ User-scoped operations
| mode | query | | No | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" |
| name | query | | No | string |
| page | query | | No | integer, <br>**Default:** 1 |
| tag | query | | No | string |
| workspace_id | query | | Yes | string |
#### Responses
@ -601,7 +600,6 @@ mode is a closed enum.
| mode | [AppMode](#appmode) | | No |
| name | string | | No |
| page | integer, <br>**Default:** 1 | | No |
| tag | string | | No |
| workspace_id | string | | Yes |
#### AppListResponse
@ -618,12 +616,10 @@ mode is a closed enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_by_name | string | | No |
| description | string | | No |
| id | string | | Yes |
| mode | [AppMode](#appmode) | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
| workspace_id | string | | No |
| workspace_name | string | | No |
@ -994,12 +990,6 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| last_used_at | string | | No |
| prefix | string | | Yes |
#### TagItem
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
#### TaskStopResponse
200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns

View File

@ -5,7 +5,7 @@ Runs against the model directly, not the HTTP layer. Pins:
- workspace_id is required.
- numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]).
- mode validates against the AppMode enum.
- name and tag have length caps.
- name has a length cap.
"""
from __future__ import annotations
@ -24,7 +24,6 @@ def test_defaults():
assert q.limit == 20
assert q.mode is None
assert q.name is None
assert q.tag is None
def test_workspace_id_required():
@ -80,12 +79,6 @@ def test_name_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 201})
def test_tag_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 100})
with pytest.raises(ValidationError):
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 101})
def test_all_fields_accept_valid_values():
"""Pin the happy-path acceptance for every field in one place."""
q = AppListQuery.model_validate(
@ -95,7 +88,6 @@ def test_all_fields_accept_valid_values():
"limit": 50,
"mode": "workflow",
"name": "search",
"tag": "prod",
}
)
assert q.workspace_id == "00000000-0000-0000-0000-000000000001"
@ -104,4 +96,3 @@ def test_all_fields_accept_valid_values():
assert q.mode is not None
assert q.mode.value == "workflow"
assert q.name == "search"
assert q.tag == "prod"

View File

@ -36,7 +36,6 @@ describe('AppsClient.list', () => {
// Optional filters are omitted entirely when not supplied.
expect(q.has('mode')).toBe(false)
expect(q.has('name')).toBe(false)
expect(q.has('tag')).toBe(false)
})
it('forwards explicit pagination and filters', async () => {
@ -48,7 +47,6 @@ describe('AppsClient.list', () => {
limit: 50,
mode: 'chat',
name: 'support bot',
tag: 'prod',
})
const q = queryOf(stub.captured.url)
@ -56,18 +54,16 @@ describe('AppsClient.list', () => {
expect(q.get('limit')).toBe('50')
expect(q.get('mode')).toBe('chat')
expect(q.get('name')).toBe('support bot')
expect(q.get('tag')).toBe('prod')
})
it('treats empty-string filters as absent (not blank query params)', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' })
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' })
const q = queryOf(stub.captured.url)
expect(q.has('mode')).toBe(false)
expect(q.has('name')).toBe(false)
expect(q.has('tag')).toBe(false)
})
it('propagates server 403 as a classified BaseError', async () => {

View File

@ -10,7 +10,6 @@ export type ListQuery = {
readonly limit?: number
readonly mode?: AppMode | ''
readonly name?: string
readonly tag?: string
}
// An absent or empty mode filter means "any mode" — collapse both to undefined for the query.
@ -33,7 +32,6 @@ export class AppsClient implements AppReader {
limit: q.limit ?? 20,
mode: normalizeMode(q.mode),
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
tag: q.tag !== undefined && q.tag !== '' ? q.tag : undefined,
},
})
}

View File

@ -1,4 +1,4 @@
import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen'
import type { AppListResponse, AppListRow } from '@dify/contracts/api/openapi/types.gen'
import type { TableCell, TableColumn } from '@/framework/output'
export const APP_MODE_KEY = 'app'
@ -7,9 +7,7 @@ export const APP_COLUMNS: readonly TableColumn[] = [
{ name: 'NAME', priority: 0 },
{ name: 'ID', priority: 0 },
{ name: 'MODE', priority: 0 },
{ name: 'TAGS', priority: 0 },
{ name: 'UPDATED', priority: 0 },
{ name: 'AUTHOR', priority: 1 },
{ name: 'WORKSPACE', priority: 1 },
]
@ -25,9 +23,7 @@ export class AppRow {
this.data.name,
this.data.id,
this.data.mode,
joinTags(this.data.tags ?? []),
this.data.updated_at ?? '',
this.data.created_by_name ?? '',
this.data.workspace_name ?? '',
]
}
@ -70,7 +66,3 @@ export class AppListOutput {
return this.envelope
}
}
function joinTags(tags: readonly TagItem[]): string {
return tags.map(t => t.name).join(',')
}

View File

@ -42,7 +42,6 @@ export default class GetApp extends DifyCommand {
'limit': Flags.string({ description: 'page size [1..200]' }),
'mode': Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }),
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
}
@ -59,7 +58,6 @@ export default class GetApp extends DifyCommand {
limitRaw: flags.limit,
mode: flags.mode as AppMode | undefined,
name: flags.name,
tag: flags.tag,
format,
}, { active: ctx.active, http: ctx.http, io: ctx.io })
return table({

View File

@ -42,13 +42,12 @@ describe('runGetApp', () => {
}))
}
it('list (no id, default format) renders table with NAME ID MODE TAGS UPDATED', async () => {
it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => {
const out = await render()
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED/)
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED/)
expect(out).toContain('Greeter')
expect(out).toContain('app-1')
expect(out).toContain('chat')
expect(out).toContain('demo')
expect(out).toContain('Workflow')
expect(out).not.toContain('app-3')
})
@ -58,9 +57,7 @@ describe('runGetApp', () => {
'NAME',
'ID',
'MODE',
'TAGS',
'UPDATED',
'AUTHOR',
'WORKSPACE',
])
})
@ -78,12 +75,6 @@ describe('runGetApp', () => {
expect(out).not.toContain('Greeter')
})
it('--tag filters server-side', async () => {
const out = await render({ tag: 'demo' })
expect(out).toContain('Greeter')
expect(out).not.toContain('Workflow')
})
it('-A all-workspaces aggregates across workspaces sorted by id', async () => {
const out = await render({ allWorkspaces: true })
expect(out).toContain('app-1')
@ -112,10 +103,9 @@ describe('runGetApp', () => {
expect(out.trim().split('\n').sort()).toEqual(['app-1', 'app-2'])
})
it('-o wide includes AUTHOR and WORKSPACE columns', async () => {
it('-o wide includes the WORKSPACE column', async () => {
const out = await render({ format: 'wide' })
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED\s+AUTHOR\s+WORKSPACE/)
expect(out).toContain('tester')
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED\s+WORKSPACE/)
expect(out).toContain('Default')
})

View File

@ -22,7 +22,6 @@ export type GetAppOptions = {
readonly limitRaw?: string
readonly mode?: AppMode
readonly name?: string
readonly tag?: string
readonly format?: string
}
@ -76,7 +75,6 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
limit: pageSize,
mode: opts.mode,
name: opts.name,
tag: opts.tag,
})
},
)
@ -109,9 +107,7 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
name: desc.info.name,
description: desc.info.description,
mode: desc.info.mode as AppMode,
tags: [],
updated_at: desc.info.updated_at,
created_by_name: undefined,
workspace_id: wsId,
workspace_name: wsName === '' ? undefined : wsName,
}],
@ -146,7 +142,6 @@ async function runAllWorkspaces(
limit,
mode: opts.mode,
name: opts.name,
tag: opts.tag,
})
merged.total += env.total
merged.data = [...merged.data, ...env.data]

View File

@ -76,7 +76,6 @@ export type AppListQuery = {
mode?: AppMode | null
name?: string | null
page?: number
tag?: string | null
workspace_id: string
}
@ -89,12 +88,10 @@ export type AppListResponse = {
}
export type AppListRow = {
created_by_name?: string | null
description?: string | null
id: string
mode: AppMode
name: string
tags?: Array<TagItem>
updated_at?: string | null
workspace_id?: string | null
workspace_name?: string | null
@ -406,10 +403,6 @@ export type SessionRow = {
prefix: string
}
export type TagItem = {
name: string
}
export type TaskStopResponse = {
result: 'success'
}
@ -605,7 +598,6 @@ export type GetAppsData = {
| 'workflow'
name?: string
page?: number
tag?: string
workspace_id: string
}
url: '/apps'

View File

@ -114,10 +114,33 @@ export const zAppListQuery = z.object({
mode: zAppMode.nullish(),
name: z.string().max(200).nullish(),
page: z.int().gte(1).optional().default(1),
tag: z.string().max(100).nullish(),
workspace_id: z.string(),
})
/**
* AppListRow
*/
export const zAppListRow = z.object({
description: z.string().nullish(),
id: z.string(),
mode: zAppMode,
name: z.string(),
updated_at: z.string().nullish(),
workspace_id: z.string().nullish(),
workspace_name: z.string().nullish(),
})
/**
* AppListResponse
*/
export const zAppListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* AppRunRequest
*/
@ -439,6 +462,17 @@ export const zPermittedExternalAppsListQuery = z.object({
page: z.int().gte(1).optional().default(1),
})
/**
* PermittedExternalAppsListResponse
*/
export const zPermittedExternalAppsListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* RevokeResponse
*/
@ -490,50 +524,6 @@ export const zSessionListResponse = z.object({
total: z.int(),
})
/**
* TagItem
*/
export const zTagItem = z.object({
name: z.string(),
})
/**
* AppListRow
*/
export const zAppListRow = z.object({
created_by_name: z.string().nullish(),
description: z.string().nullish(),
id: z.string(),
mode: zAppMode,
name: z.string(),
tags: z.array(zTagItem).optional().default([]),
updated_at: z.string().nullish(),
workspace_id: z.string().nullish(),
workspace_name: z.string().nullish(),
})
/**
* AppListResponse
*/
export const zAppListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* PermittedExternalAppsListResponse
*/
export const zPermittedExternalAppsListResponse = z.object({
data: z.array(zAppListRow),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* TaskStopResponse
*
@ -720,7 +710,6 @@ export const zGetAppsQuery = z.object({
.optional(),
name: z.string().max(200).optional(),
page: z.int().gte(1).optional().default(1),
tag: z.string().max(100).optional(),
workspace_id: z.string(),
})