From c6e9cca47fca85b45eabea88a435384530090af8 Mon Sep 17 00:00:00 2001 From: chariri Date: Fri, 26 Jun 2026 03:31:26 +0900 Subject: [PATCH] refactor(api): remove remaining legacy field remnants --- api/controllers/console/files.py | 8 +- api/controllers/console/spec.py | 16 +++- api/controllers/mcp/mcp.py | 21 ++--- api/fields/raws.py | 20 ----- api/openapi/markdown/console-openapi.md | 10 ++- api/services/summary_index_service.py | 1 + .../controllers/console/test_spec.py | 18 ++++- .../generated/api/console/spec/types.gen.ts | 10 ++- .../generated/api/console/spec/zod.gen.ts | 11 ++- packages/contracts/openapi-ts.api.config.ts | 78 ++++++++++++++++++- 10 files changed, 148 insertions(+), 45 deletions(-) delete mode 100644 api/fields/raws.py diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 5197120c135..a81be1e77f0 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -27,6 +27,7 @@ from controllers.console.wraps import ( ) from extensions.ext_database import db from fields.file_fields import FileResponse, UploadConfig +from libs.helper import dump_response from libs.login import login_required from models import Account from services.file_service import FileService @@ -100,8 +101,7 @@ class FileApi(Resource): except services.errors.file.BlockedFileExtensionError as blocked_extension_error: raise BlockedFileExtensionError(blocked_extension_error.description) - response = FileResponse.model_validate(upload_file, from_attributes=True) - return response.model_dump(mode="json"), 201 + return dump_response(FileResponse, upload_file), 201 @console_ns.route("/files//preview") @@ -114,7 +114,7 @@ class FilePreviewApi(Resource): def get(self, current_tenant_id: str, file_id: UUID): file_id_str = str(file_id) text = FileService(db.engine).get_file_preview(file_id_str, current_tenant_id) - return {"content": text} + return TextContentResponse(content=text).model_dump(mode="json") @console_ns.route("/files/support-type") @@ -124,4 +124,4 @@ class FileSupportTypeApi(Resource): @account_initialization_required @console_ns.response(200, "Success", console_ns.models[AllowedExtensionsResponse.__name__]) def get(self): - return {"allowed_extensions": list(DOCUMENT_EXTENSIONS)} + return AllowedExtensionsResponse(allowed_extensions=list(DOCUMENT_EXTENSIONS)).model_dump(mode="json") diff --git a/api/controllers/console/spec.py b/api/controllers/console/spec.py index 27b07b4dd81..70e0d1d14ae 100644 --- a/api/controllers/console/spec.py +++ b/api/controllers/console/spec.py @@ -1,8 +1,9 @@ import logging +from collections.abc import Mapping from typing import Any from flask_restx import Resource -from pydantic import RootModel +from pydantic import Field, RootModel from controllers.common.schema import register_response_schema_models from controllers.console.wraps import ( @@ -10,6 +11,7 @@ from controllers.console.wraps import ( setup_required, ) from core.schemas.schema_manager import SchemaManager +from fields.base import ResponseModel from libs.login import login_required from . import console_ns @@ -17,11 +19,17 @@ from . import console_ns logger = logging.getLogger(__name__) -class SchemaDefinitionsResponse(RootModel[Any]): - root: Any +class SchemaDefinitionItemResponse(ResponseModel): + name: str + label: str + schema_: Mapping[str, Any] = Field(alias="schema") -register_response_schema_models(console_ns, SchemaDefinitionsResponse) +class SchemaDefinitionsResponse(RootModel[list[SchemaDefinitionItemResponse]]): + pass + + +register_response_schema_models(console_ns, SchemaDefinitionItemResponse, SchemaDefinitionsResponse) @console_ns.route("/spec/schema-definitions") diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index cda6b915018..46f9ab644b5 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -2,11 +2,11 @@ from typing import Any, Union from flask import Response from flask_restx import Resource -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, Field, RootModel, ValidationError from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker -from controllers.common.schema import register_schema_model +from controllers.common.schema import register_response_schema_models, register_schema_model from controllers.mcp import mcp_ns from core.mcp import types as mcp_types from core.mcp.server.streamable_http import handle_mcp_request @@ -33,7 +33,12 @@ class MCPRequestPayload(BaseModel): id: int | str | None = Field(default=None, description="Request ID for tracking responses") +class MCPJSONRPCResponse(RootModel[mcp_types.JSONRPCResponse | mcp_types.JSONRPCError]): + pass + + register_schema_model(mcp_ns, MCPRequestPayload) +register_response_schema_models(mcp_ns, MCPJSONRPCResponse) @mcp_ns.route("/server//mcp") @@ -42,13 +47,10 @@ class MCPAppApi(Resource): @mcp_ns.doc("handle_mcp_request") @mcp_ns.doc(description="Handle Model Context Protocol (MCP) requests for a specific server") @mcp_ns.doc(params={"server_code": "Unique identifier for the MCP server"}) - @mcp_ns.doc( - responses={ - 200: "MCP response successfully processed", - 400: "Invalid MCP request or parameters", - 404: "Server or app not found", - } - ) + @mcp_ns.response(200, "MCP JSON-RPC response", mcp_ns.models[MCPJSONRPCResponse.__name__]) + @mcp_ns.response(202, "MCP notification accepted") + @mcp_ns.response(400, "Invalid MCP request or parameters") + @mcp_ns.response(404, "Server or app not found") def post(self, server_code: str): """Handle MCP requests for a specific server. @@ -64,6 +66,7 @@ class MCPAppApi(Resource): Raises: ValidationError: Invalid request format or parameters """ + # response-contract:ignore MCP route returns Flask Response from JSON-RPC handler args = MCPRequestPayload.model_validate(mcp_ns.payload or {}) request_id: Union[int, str] | None = args.id mcp_request = self._parse_mcp_request(args.model_dump(exclude_none=True)) diff --git a/api/fields/raws.py b/api/fields/raws.py deleted file mode 100644 index c7e047626f1..00000000000 --- a/api/fields/raws.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import override - -from flask_restx import fields - -from graphon.file import File - - -class FilesContainedField(fields.Raw): - @override - def format(self, value): - return self._format_file_object(value) - - def _format_file_object(self, v): - if isinstance(v, File): - return v.model_dump() - if isinstance(v, dict): - return {k: self._format_file_object(vv) for k, vv in v.items()} - if isinstance(v, list): - return [self._format_file_object(vv) for vv in v] - return v diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b3a0b8a6a71..58bb08f563d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -19026,11 +19026,19 @@ Model class for provider quota configuration. | last_id | string | | No | | limit | integer,
**Default:** 20 | | No | +#### SchemaDefinitionItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| label | string | | Yes | +| name | string | | Yes | +| schema | object | | Yes | + #### SchemaDefinitionsResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| SchemaDefinitionsResponse | | | | +| SchemaDefinitionsResponse | array | | | #### SegmentAttachmentResponse diff --git a/api/services/summary_index_service.py b/api/services/summary_index_service.py index 1657cfdd256..97aee92812c 100644 --- a/api/services/summary_index_service.py +++ b/api/services/summary_index_service.py @@ -1423,6 +1423,7 @@ class SummaryIndexService: - generating: Number of summaries being generated - error: Number of summaries with errors - not_started: Number of segments without summary records + - timeout: Number of summaries that timed out - summaries: List of summary records with status and content preview """ from services.dataset_service import SegmentService diff --git a/api/tests/unit_tests/controllers/console/test_spec.py b/api/tests/unit_tests/controllers/console/test_spec.py index 84c2004ec70..8b79bcd82fc 100644 --- a/api/tests/unit_tests/controllers/console/test_spec.py +++ b/api/tests/unit_tests/controllers/console/test_spec.py @@ -9,7 +9,17 @@ class TestSpecSchemaDefinitionsApi: api = spec_module.SpecSchemaDefinitionsApi() method = unwrap(api.get) - schema_definitions = [{"type": "string"}] + schema_definitions = [ + { + "name": "conversation-variable", + "label": "Conversation variable", + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + } + ] with patch.object( spec_module, @@ -21,6 +31,12 @@ class TestSpecSchemaDefinitionsApi: assert status == 200 assert resp == schema_definitions + assert spec_module.SchemaDefinitionsResponse.model_validate(resp).model_dump(mode="json") == schema_definitions + + def test_get_documents_tight_response_model(self): + response = spec_module.SpecSchemaDefinitionsApi.get.__apidoc__["responses"]["200"] + + assert response[1].name == spec_module.SchemaDefinitionsResponse.__name__ def test_get_exception_returns_empty_list(self): api = spec_module.SpecSchemaDefinitionsApi() diff --git a/packages/contracts/generated/api/console/spec/types.gen.ts b/packages/contracts/generated/api/console/spec/types.gen.ts index e7b9c0ea9b2..853fb2cccb1 100644 --- a/packages/contracts/generated/api/console/spec/types.gen.ts +++ b/packages/contracts/generated/api/console/spec/types.gen.ts @@ -4,7 +4,15 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } -export type SchemaDefinitionsResponse = unknown +export type SchemaDefinitionsResponse = Array + +export type SchemaDefinitionItemResponse = { + label: string + name: string + schema: { + [key: string]: unknown + } +} export type GetSpecSchemaDefinitionsData = { body?: never diff --git a/packages/contracts/generated/api/console/spec/zod.gen.ts b/packages/contracts/generated/api/console/spec/zod.gen.ts index 8fc487d5ac4..b1135d7f34d 100644 --- a/packages/contracts/generated/api/console/spec/zod.gen.ts +++ b/packages/contracts/generated/api/console/spec/zod.gen.ts @@ -2,10 +2,19 @@ import * as z from 'zod' +/** + * SchemaDefinitionItemResponse + */ +export const zSchemaDefinitionItemResponse = z.object({ + label: z.string(), + name: z.string(), + schema: z.record(z.string(), z.unknown()), +}) + /** * SchemaDefinitionsResponse */ -export const zSchemaDefinitionsResponse = z.unknown() +export const zSchemaDefinitionsResponse = z.array(zSchemaDefinitionItemResponse) /** * Success diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 8fce8a25bd3..1adbf4fda8e 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -10,13 +10,21 @@ type SwaggerSchema = JsonObject & { $ref?: string } +type OpenApiMediaType = JsonObject & { + schema?: SwaggerSchema +} + +type OpenApiResponse = JsonObject & { + content?: Record +} + type OpenApiComponents = JsonObject & { schemas?: Record } type SwaggerOperation = JsonObject & { operationId?: string - responses?: Record + responses?: Record } type SwaggerDocument = JsonObject & { @@ -52,6 +60,17 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url)) const apiOpenApiDir = path.resolve(currentDir, 'openapi') const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) +const pydanticDecimalStringPattern = '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$' +const codegenSafeDecimalStringPattern = '^(?![-+.]*$)[+-]?0*\\d*\\.?\\d*$' + +const opaqueJsonContent = (): Record => ({ + 'application/json': { + schema: { + additionalProperties: true, + type: 'object', + }, + }, +}) const apiSpecs: ApiSpec[] = [ { filename: 'console-openapi.json', name: 'console' }, @@ -182,6 +201,46 @@ const addOperationIds = (document: SwaggerDocument) => { } } +const isOpaqueContractResponse = (response: OpenApiResponse) => { + const content = response.content + if (!isObject(content)) + return false + + return Object.entries(content).some(([mediaType, media]) => { + if (!isObject(media)) + return false + + return (mediaType === 'application/json' || mediaType === 'text/event-stream') && !('schema' in media) + }) +} + +const hasOpaqueContractSuccessResponse = (operation: SwaggerOperation) => { + return Object.entries(operation.responses ?? {}).some(([status, response]) => { + return /^2\d\d$/.test(status) && isObject(response) && isOpaqueContractResponse(response) + }) +} + +const normalizeOpaqueContractResponses = (document: SwaggerDocument) => { + // Some backend endpoints has no schema (e.g. external) and will trap heyapi here + // So we forge an opaque schema here + for (const pathItem of Object.values(document.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!operationMethods.has(method) || !isObject(operation)) + continue + + const swaggerOperation = operation as SwaggerOperation + if (!hasOpaqueContractSuccessResponse(swaggerOperation)) + continue + + Object.values(swaggerOperation.responses ?? {}) + .filter(response => isObject(response) && isOpaqueContractResponse(response)) + .forEach((response) => { + response.content = opaqueJsonContent() + }) + } + } +} + const hasSuccessResponse = (operation: SwaggerOperation) => { return Object.entries(operation.responses ?? {}).some(([status, response]) => { if (!/^2\d\d$/.test(status)) @@ -215,6 +274,7 @@ const filterContractOperations = (document: SwaggerDocument) => { } const normalizeApiSwagger = (document: SwaggerDocument) => { + normalizeOpaqueContractResponses(document) filterContractOperations(document) addOperationIds(document) @@ -380,10 +440,20 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ 'name': 'zod', '~resolvers': { string: (ctx) => { - if (ctx.schema.format !== 'binary') - return undefined + if (ctx.schema.format === 'binary') + return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File'))) - return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File'))) + if (ctx.schema.pattern === pydanticDecimalStringPattern) { + // the pydantic generated regex will emit error like + // regexp/no-useless-assertions, so patch the regex here + return $(ctx.symbols.z) + .attr('string') + .call() + .attr('regex') + .call($.regexp(codegenSafeDecimalStringPattern)) + } + + return undefined }, }, },