feat(web): export OpenAPI mapper types and compatible contract subpaths (#44915)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhouandautofix-ci[bot] authored and GitHub committed 2026-10-28 14:39:37 +00:00
1 parent 430d9aff99
commit 04ba877c1a
165 files changed
+3287 -624

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
run: pnpm --filter @dify/openapi-content-mapper test:next
- name: Verify standalone Console imports, error maps and editor support
run: pnpm --filter @dify/openapi-content-mapper exec node --test --test-concurrency=1 integration/knowledge-fs-console.test.mjs integration/orpc-errors.test.mjs integration/console-preparation.test.mjs integration/console-editor.test.mjs
run: pnpm --filter @dify/openapi-content-mapper exec node --test --test-concurrency=1 integration/knowledge-fs-console.test.mjs integration/orpc-errors.test.mjs integration/console-preparation.test.mjs integration/console-editor.test.mjs integration/type-navigation.test.mjs
- name: Check all Console types against the reviewed migration differences
run: pnpm --filter @dify/openapi-content-mapper check:console
+5
View File
@@ -83,6 +83,11 @@ _registered_schemas: WeakKeyDictionary[Namespace, dict[str, tuple[str, str, dict
_NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]")
def registered_schema_sources(namespace: Namespace) -> dict[str, str]:
"""Return component identities for editor tooling without exposing mutable schemas."""
return {name: source for name, source, _ in _registered_schemas.get(namespace, {}).values()}
def _normalized_schema_name(name: str) -> str:
"""Fold names that client code generators turn into the same identifier (`JSONValue` / `JsonValue`)."""
+11 -8
View File
@@ -27,6 +27,8 @@ from pathlib import Path
from typing import Any, Never
API_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOTS = (API_ROOT, API_ROOT.parent / "dify-agent/src")
PACKAGE_INPUTS = (API_ROOT / "pyproject.toml", API_ROOT / "uv.lock", API_ROOT.parent / "dify-agent/pyproject.toml")
TIMEOUT = 90
IDLE_TIMEOUT = 120
MAX_BYTES = 64 * 1024 * 1024
@@ -38,7 +40,7 @@ def source_imports() -> None:
def get_code(self: importlib.machinery.SourceFileLoader, fullname: str):
path = Path(self.path)
if path.is_relative_to(API_ROOT) and ".venv" not in path.parts:
if any(path.is_relative_to(root) for root in SOURCE_ROOTS) and ".venv" not in path.parts:
return self.source_to_code(self.get_data(self.path), self.path)
return original(self, fullname)
@@ -48,12 +50,13 @@ def source_imports() -> None:
def source_state() -> dict[str, tuple[int, int, int]]:
"""Match the Console descriptor's inputs, excluding tests and local environments."""
files = [API_ROOT / "pyproject.toml", API_ROOT / "uv.lock", Path.cwd() / ".env"]
for directory, children, names in os.walk(API_ROOT):
children[:] = [name for name in children if name not in {".venv", ".git", "__pycache__"}]
if Path(directory) == API_ROOT:
children[:] = [name for name in children if name != "tests"]
files.extend(Path(directory) / name for name in names if name.endswith(".py"))
files = [*PACKAGE_INPUTS, Path.cwd() / ".env"]
for root in SOURCE_ROOTS:
for directory, children, names in os.walk(root):
children[:] = [name for name in children if name not in {".venv", ".git", "__pycache__"}]
if Path(directory) == API_ROOT:
children[:] = [name for name in children if name != "tests"]
files.extend(Path(directory) / name for name in names if name.endswith(".py"))
return {str(file): file_stamp(file) for file in files}
@@ -141,7 +144,7 @@ def preload() -> tuple[bool, dict[str, tuple[int, int, int]]]:
filename = getattr(module, "__file__", None) # guard-ignore: no-new-getattr -- optional module source metadata
if isinstance(filename, str):
dependencies[str(Path(filename).resolve())] = file_stamp(Path(filename))
for filename in (API_ROOT / "pyproject.toml", API_ROOT / "uv.lock", Path.cwd() / ".env"):
for filename in (*PACKAGE_INPUTS, Path.cwd() / ".env"):
dependencies[str(filename)] = file_stamp(filename)
return safe, dependencies
+62 -21
View File
@@ -10,24 +10,74 @@ from pathlib import Path
from typing import Any
def attach_source_location(document: dict[str, Any], path: str, method: str, handler: Any) -> None:
"""Attach editor-only origins to exported operations, leaving live schemas unchanged."""
operation = document["paths"].get(path, {}).get(method.lower())
if not isinstance(operation, dict) or handler is None:
return
def source_location(target: Any) -> dict[str, Any] | None:
"""Locate the actual class or function definition, skipping decorators."""
try:
handler = inspect.unwrap(handler)
filename = inspect.getsourcefile(handler)
lines, start = inspect.getsourcelines(handler)
target = inspect.unwrap(target)
filename = inspect.getsourcefile(target)
lines, start = inspect.getsourcelines(target)
definition = ast.parse(textwrap.dedent("".join(lines))).body[0]
except (OSError, TypeError, ValueError, SyntaxError):
# Dynamically created or native handlers have no navigable Python source.
return
if filename and isinstance(definition, (ast.FunctionDef, ast.AsyncFunctionDef)):
operation["x-source-location"] = {
return None
if filename and isinstance(definition, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
return {
"file": str(Path(filename).resolve()),
"line": start + definition.lineno - 1,
}
return None
def resolve_source_type(source: str) -> Any:
"""Resolve registered identities only, including nested classes and generic origins."""
parts = source.split("[", 1)[0].split(".")
for end in range(len(parts) - 1, 0, -1):
target = sys.modules.get(".".join(parts[:end]))
if target is None:
continue
for part in parts[end:]:
if not inspect.ismodule(target) and not inspect.isclass(target):
return None
target = vars(target).get(part)
return target if inspect.isclass(target) else None
return None
def attach_source_location(document: dict[str, Any], path: str, method: str, handler: Any) -> None:
"""Attach editor-only origins to exported operations, leaving live schemas unchanged."""
operation = document["paths"].get(path, {}).get(method.lower())
if isinstance(operation, dict) and (location := source_location(handler)):
operation["x-source-location"] = location
def attach_api_sources(document: dict[str, Any], api: Any) -> None:
"""Annotate exported RESTX models and operations from their registered Python owners."""
from flask_restx.swagger import extract_path
from controllers.common.schema import registered_schema_sources
schemas = document.get("components", {}).get("schemas", {})
for namespace in api.namespaces:
for name, source in registered_schema_sources(namespace).items():
if name in schemas and (location := source_location(resolve_source_type(source))):
schemas[name]["x-source-location"] = location
for resource in namespace.resources:
cls = resource.resource
for url in api.ns_urls(namespace, resource.urls):
for method in cls.methods:
handler = getattr(cls, method.lower(), None) # guard-ignore: no-new-getattr -- HTTP method
attach_source_location(document, extract_path(url), method, handler)
def export_all_with_sources() -> dict[str, dict[str, Any]]:
"""Export every RESTX surface with editor metadata, without writing spec files."""
from dev.generate_swagger_specs import SPEC_TARGETS, _target_apis, export_specs
documents = export_specs()
apis = _target_apis()
for target in SPEC_TARGETS:
attach_api_sources(documents[target.filename], apis[target.route])
return documents
def main() -> None:
@@ -41,19 +91,10 @@ def main() -> None:
"console": export_console_spec(),
"fast": export_fastopenapi_specs()["fastopenapi-console-openapi.json"],
}
from flask_restx.swagger import extract_path
from controllers.console import api
from controllers.fastopenapi import console_router
for namespace in api.namespaces:
for resource in namespace.resources:
cls = resource.resource
for url in api.ns_urls(namespace, resource.urls):
path = extract_path(url)
for method in cls.methods:
handler = getattr(cls, method.lower(), None) # guard-ignore: no-new-getattr -- HTTP method
attach_source_location(documents["console"], path, method, handler)
attach_api_sources(documents["console"], api)
for path, method, handler in console_router.get_routes():
attach_source_location(documents["fast"], f"/console/api{path}", method, handler)
json.dump(documents, sys.stdout, sort_keys=True)
@@ -3,6 +3,7 @@
import concurrent.futures
import json
import os
import py_compile
import shutil
import subprocess
import sys
@@ -147,6 +148,36 @@ def test_environment_and_file_additions_invalidate_memory_cache(backend: CacheCl
assert call()["snapshot"] != added["snapshot"]
def test_sibling_python_models_refresh_cached_exports_without_stale_bytecode(backend: CacheClient) -> None:
package = backend.api.parent / "dify-agent/src/citation_fixture"
package.mkdir(parents=True)
(package / "__init__.py").touch()
model = package / "model.py"
model.write_text("FIELD = 'string'\n")
compiled = py_compile.compile(str(model), doraise=True)
assert compiled is not None
bytecode = Path(compiled).read_bytes()
# This module is imported by the export worker, not by the preloaded snapshot.
(backend.api / "controllers/fixture.py").write_text("from citation_fixture.model import FIELD\n")
backend.env["PYTHONPATH"] = os.pathsep.join([str(backend.api), str(package.parent)])
first = backend.export()
assert first["field"] == "string"
assert backend.export() == first
stat = model.stat()
model.write_text("FIELD = 'number'\n")
os.utime(model, ns=(stat.st_atime_ns, stat.st_mtime_ns))
changed = backend.export()
assert changed["field"] == "number"
assert changed["pid"] != first["pid"]
added = package / "new_model.py"
added.write_text("VALUE = 1\n")
with_added = backend.export()
assert with_added["pid"] != changed["pid"]
added.unlink()
assert backend.export()["pid"] != with_added["pid"]
assert Path(compiled).read_bytes() == bytecode
def test_native_threads_use_fresh_exports_and_propagate_failures(backend: CacheClient) -> None:
model = backend.api / "models.py"
model.write_text(
@@ -0,0 +1,75 @@
"""Editor metadata must follow registered Python identities without changing live schemas."""
import inspect
from collections.abc import Callable
from copy import deepcopy
from functools import wraps
from pathlib import Path
from flask_restx import Api, Namespace, Resource
from pydantic import BaseModel
from controllers.common.schema import register_schema_model, registered_schema_sources
from dev.export_console_openapi import attach_api_sources, resolve_source_type, source_location
class NavigationPayload(BaseModel):
name: str
class Container:
class Nested(BaseModel):
value: str
def decorated[**P, R](handler: Callable[P, R]) -> Callable[P, R]:
@wraps(handler)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return handler(*args, **kwargs)
return wrapper
class NavigationResource(Resource):
@decorated
def post(self) -> dict[str, str]:
return {}
def test_registered_models_and_decorated_operations_have_exact_origins() -> None:
namespace = Namespace("navigation", path="/navigation")
register_schema_model(namespace, NavigationPayload)
namespace.add_resource(NavigationResource, "")
api = Api()
api.add_namespace(namespace)
live_schema = deepcopy(namespace.models["NavigationPayload"].__schema__)
document = {
"components": {"schemas": {"NavigationPayload": deepcopy(live_schema), "Synthetic": {"type": "object"}}},
"paths": {"/navigation": {"post": {"description": "Create"}}},
}
attach_api_sources(document, api)
model_source = document["components"]["schemas"]["NavigationPayload"]["x-source-location"]
assert model_source == {"file": str(Path(__file__).resolve()), "line": inspect.getsourcelines(NavigationPayload)[1]}
operation_source = document["paths"]["/navigation"]["post"]["x-source-location"]
expected_line = next(
line
for line, text in enumerate(Path(__file__).read_text().splitlines(), 1)
if text.strip() == "def post(self) -> dict[str, str]:"
)
assert operation_source == {"file": str(Path(__file__).resolve()), "line": expected_line}
assert "x-source-location" not in document["components"]["schemas"]["Synthetic"]
assert namespace.models["NavigationPayload"].__schema__ == live_schema
sources = registered_schema_sources(namespace)
sources.clear()
assert registered_schema_sources(namespace)["NavigationPayload"].endswith(".NavigationPayload")
def test_exact_nested_and_generic_identities_resolve_without_name_guessing() -> None:
assert resolve_source_type(f"{__name__}.Container.Nested") is Container.Nested
assert resolve_source_type(f"{__name__}.Container.Nested[builtins.str]") is Container.Nested
assert resolve_source_type("unknown.module.NavigationPayload") is None
assert resolve_source_type(f"{__name__}.Missing.NavigationPayload") is None
assert source_location(None) is None
assert source_location(str) is None
+458
View File
@@ -0,0 +1,458 @@
type JsonObject = Record<string, unknown>
export type SwaggerSchema = JsonObject & {
$ref?: string
}
type OpenApiMediaType = JsonObject & {
schema?: SwaggerSchema
}
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
type OpenApiComponents = JsonObject & {
schemas?: Record<string, SwaggerSchema>
}
export type SwaggerOperation = JsonObject & {
operationId?: string
responses?: Record<string, OpenApiResponse>
}
export type SwaggerDocument = JsonObject & {
components?: OpenApiComponents
openapi?: string
paths?: Record<string, Record<string, unknown>>
}
export type ApiSpec = {
filename: string
name: string
}
type ApiContractOperation = {
method: string
path: string
}
export const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
export const fastOpenApiConsoleSpecFilename = 'fastopenapi-console-openapi.json'
const fastOpenApiConsolePathPrefix = '/console/api'
export const apiSpecs: ApiSpec[] = [
{ filename: 'console-openapi.json', name: 'console' },
{ filename: 'web-openapi.json', name: 'web' },
{ filename: 'service-openapi.json', name: 'service' },
{ filename: 'openapi-openapi.json', name: 'openapi' },
]
export const isObject = (value: unknown): value is JsonObject => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const toWords = (value: string) => {
return value
.replace(/[{}]/g, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^a-z0-9]+/i)
.filter(Boolean)
}
const toPascalCase = (words: string[]) => {
return words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('')
}
export const toCamelCase = (words: string[]) => {
const pascal = toPascalCase(words)
return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`
}
export const toKebabCase = (value: string) => {
return toWords(value).join('-').toLowerCase()
}
export const segmentWords = (segment: string) => {
if (segment.startsWith('{') && segment.endsWith('}')) return ['by', ...toWords(segment)]
return toWords(segment)
}
// Split on `:` too so custom methods nest as their own node (apps.byAppId.run), not apps.appIdRun.
const routeNamingSegments = (routePath: string) => routePath.split(/[/:]/).filter(Boolean)
const routeWords = (routePath: string) => {
return routeNamingSegments(routePath).flatMap(segmentWords)
}
const operationId = (method: string, routePath: string) => {
return toCamelCase([
method,
...(routeWords(routePath).length > 0 ? routeWords(routePath) : ['root']),
])
}
export const contractPathSegments = (operation: ApiContractOperation) => {
const segments = routeNamingSegments(operation.path).map((segment) =>
toCamelCase(segmentWords(segment)),
)
return [...(segments.length > 0 ? segments : ['root']), operation.method.toLowerCase()]
}
const clone = <T>(value: T): T => {
return JSON.parse(JSON.stringify(value)) as T
}
const componentSchemaRefPrefix = '#/components/schemas/'
export const schemaNameFromRef = (ref: string) => {
if (ref.startsWith(componentSchemaRefPrefix)) return ref.slice(componentSchemaRefPrefix.length)
return undefined
}
export const getDocumentSchemas = (document: SwaggerDocument) => {
const components = (document.components ??= {})
return (components.schemas ??= {})
}
const collectSchemaRefs = (value: unknown, refs: Set<string>, visited = new WeakSet<object>()) => {
if (!value || typeof value !== 'object') return
if (visited.has(value)) return
visited.add(value)
if (Array.isArray(value)) {
value.forEach((item) => collectSchemaRefs(item, refs, visited))
return
}
const objectValue = value as JsonObject
const ref = objectValue.$ref
if (typeof ref === 'string') {
const refName = schemaNameFromRef(ref)
if (refName) refs.add(refName)
}
Object.values(objectValue).forEach((item) => collectSchemaRefs(item, refs, visited))
}
const addOperationIds = (document: SwaggerDocument) => {
for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation)) continue
const swaggerOperation = operation as SwaggerOperation
swaggerOperation.operationId = operationId(method, routePath)
}
}
}
const normalizeOpaqueContractResponses = (document: SwaggerDocument) => {
// This runs before contract filtering. Flask-RESTX often emits plain success responses
// without a body schema; give those routes an opaque output so they stay in oRPC.
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
for (const [status, response] of Object.entries(swaggerOperation.responses ?? {})) {
// Ignore non-2xx or 204 or those w/o a response field
if (!/^2\d\d$/.test(status) || status === '204' || !isObject(response)) continue
const content = response.content
if (!isObject(content) || Object.keys(content).length === 0) {
// No response specification, fill a dummy opaque resp
response.content = {
'application/json': {
schema: {
additionalProperties: true,
type: 'object',
},
},
}
continue
}
for (const [mediaType, media] of Object.entries(content)) {
if (mediaType !== 'application/json' && mediaType !== 'text/event-stream') continue
if (!isObject(media) || isObject(media.schema)) continue
// JSON/SSE media without a schema traps heyapi. Patch only that media entry so
// sibling binary media keeps heyapi's Blob | File inference.
// Still a dummy opaque resp
media.schema = {
additionalProperties: true,
type: 'object',
}
}
}
}
}
}
const hasSuccessResponse = (operation: SwaggerOperation) => {
return Object.entries(operation.responses ?? {}).some(([status, response]) => {
if (!/^2\d\d$/.test(status)) return false
if (!isObject(response)) return false
const content = (response as JsonObject).content
// 204 No Content is a valid success response without a body
if (!isObject(content) || Object.keys(content).length === 0) return status === '204'
return true
})
}
const filterContractOperations = (document: SwaggerDocument) => {
for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation)) continue
if (!hasSuccessResponse(operation as SwaggerOperation)) delete pathItem[method]
}
const hasOperations = Object.entries(pathItem).some(
([method, operation]) => operationMethods.has(method) && isObject(operation),
)
if (!hasOperations) delete document.paths?.[routePath]
}
}
const includeMultipartRequestSchemas = (document: SwaggerDocument) => {
for (const pathItem of Object.values(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation) || !isObject(operation.requestBody))
continue
const content = operation.requestBody.content
if (!isObject(content)) continue
const json = content['application/json']
const multipart = content['multipart/form-data']
if (
!isObject(json) ||
!isObject(json.schema) ||
!isObject(multipart) ||
!isObject(multipart.schema)
)
continue
// hey-api selects JSON for mixed request media; retain the multipart shape
// so the generated client can serialize File values as FormData.
json.schema = { anyOf: [json.schema, multipart.schema] }
}
}
}
const includeNonJsonResponseSchemas = (document: SwaggerDocument) => {
for (const pathItem of Object.values(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation)) continue
for (const [status, response] of Object.entries(
(operation as SwaggerOperation).responses ?? {},
)) {
if (!/^2\d\d$/.test(status) || !isObject(response) || !isObject(response.content)) continue
const jsonMedia = response.content['application/json']
if (!isObject(jsonMedia) || !isObject(jsonMedia.schema)) continue
const alternatives: SwaggerSchema[] = []
for (const [mediaType, media] of Object.entries(response.content)) {
if (mediaType === 'application/json' || !isObject(media) || !isObject(media.schema))
continue
if (mediaType === 'text/event-stream' || media.schema.format === 'binary')
alternatives.push(media.schema)
}
if (alternatives.length === 0) continue
// hey-api selects the JSON schema when one status advertises multiple
// response media types. Preserve SSE and binary transports in the generated
// TypeScript and Zod contracts by making that selected schema a union.
jsonMedia.schema = {
anyOf: [jsonMedia.schema, ...alternatives],
}
}
}
}
}
const normalizeCodegenSchemas = (document: SwaggerDocument) => {
const visitedSchemas = new WeakSet<object>()
const visitSchema = (value: unknown) => {
if (!isObject(value) || visitedSchemas.has(value)) return
visitedSchemas.add(value)
if (value.default === null) delete value.default
// Dify serializes int64 fields as JSON numbers. hey-api maps int64 to
// bigint in Zod, which disagrees with both the wire value and its own
// generated TypeScript type. Keep int64 in the published OpenAPI specs,
// but generate number-based client validators from this in-memory copy.
if (value.type === 'integer' && value.format === 'int64') delete value.format
for (const key of [
'additionalProperties',
'contains',
'else',
'if',
'items',
'not',
'propertyNames',
'then',
'unevaluatedItems',
'unevaluatedProperties',
]) {
visitSchema(value[key])
}
for (const key of ['allOf', 'anyOf', 'oneOf', 'prefixItems']) {
const schemas = value[key]
if (Array.isArray(schemas)) schemas.forEach(visitSchema)
}
for (const key of [
'$defs',
'definitions',
'dependentSchemas',
'patternProperties',
'properties',
]) {
const schemas = value[key]
if (isObject(schemas)) Object.values(schemas).forEach(visitSchema)
}
}
const visitedDocumentObjects = new WeakSet<object>()
const findSchemas = (value: unknown, parentKey?: string) => {
if (!value || typeof value !== 'object' || visitedDocumentObjects.has(value)) return
visitedDocumentObjects.add(value)
if (parentKey === 'schema') {
visitSchema(value)
return
}
if (parentKey === 'schemas' && isObject(value)) {
Object.values(value).forEach(visitSchema)
return
}
if (parentKey === 'example' || parentKey === 'examples') return
if (Array.isArray(value)) {
value.forEach((item) => findSchemas(item))
return
}
Object.entries(value).forEach(([key, item]) => findSchemas(item, key))
}
findSchemas(document)
}
export const normalizeApiSwagger = (document: SwaggerDocument) => {
normalizeOpaqueContractResponses(document)
filterContractOperations(document)
addOperationIds(document)
includeMultipartRequestSchemas(document)
includeNonJsonResponseSchemas(document)
// OpenAPI defaults describe server behavior. Keep them in the exported specs,
// but do not let Zod synthesize omitted transport fields during client-side
// request or response validation. Non-null defaults remain useful for query
// parameter ergonomics and preserve the existing generated contract behavior.
normalizeCodegenSchemas(document)
return document
}
export const mergeFastOpenApiConsoleSwagger = (
document: SwaggerDocument,
fastOpenApiDocument: SwaggerDocument,
) => {
const targetPaths = (document.paths ??= {})
for (const [routePath, pathItem] of Object.entries(fastOpenApiDocument.paths ?? {})) {
const contractPath = routePath.startsWith(fastOpenApiConsolePathPrefix)
? routePath.slice(fastOpenApiConsolePathPrefix.length) || '/'
: routePath
if (targetPaths[contractPath])
throw new Error(`Duplicate console API path after FastOpenAPI merge: ${contractPath}`)
targetPaths[contractPath] = pathItem
}
const targetSchemas = getDocumentSchemas(document)
const sourceSchemas = getDocumentSchemas(fastOpenApiDocument)
for (const [schemaName, schema] of Object.entries(sourceSchemas)) {
if (targetSchemas[schemaName])
throw new Error(`Duplicate console API schema after FastOpenAPI merge: ${schemaName}`)
targetSchemas[schemaName] = schema
}
return document
}
export const topLevelPathSegment = (routePath: string) => {
return routePath.split('/').filter(Boolean)[0] ?? 'root'
}
export const groupConsolePaths = (document: SwaggerDocument) => {
const groups = new Map<string, Record<string, Record<string, unknown>>>()
for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) {
const segment = topLevelPathSegment(routePath)
const paths = groups.get(segment) ?? {}
paths[routePath] = pathItem
groups.set(segment, paths)
}
return groups
}
const selectReferencedSchemas = (
schemas: Record<string, SwaggerSchema>,
paths: Record<string, Record<string, unknown>>,
) => {
const selectedSchemas: Record<string, SwaggerSchema> = {}
const pendingRefs = new Set<string>()
collectSchemaRefs(paths, pendingRefs)
while (pendingRefs.size > 0) {
const refName = pendingRefs.values().next().value
if (!refName) break
pendingRefs.delete(refName)
if (selectedSchemas[refName]) continue
const schema = schemas[refName]
if (!schema) throw new Error(`Missing referenced schema: ${refName}`)
selectedSchemas[refName] = schema
const nestedRefs = new Set<string>()
collectSchemaRefs(selectedSchemas[refName], nestedRefs)
for (const nestedRef of nestedRefs) {
if (!selectedSchemas[nestedRef]) pendingRefs.add(nestedRef)
}
}
return selectedSchemas
}
export const cloneDocumentWithPaths = (
document: SwaggerDocument,
paths: Record<string, Record<string, unknown>>,
) => {
const { components: _components, paths: _paths, ...metadata } = document
const clonedPaths = clone(paths)
const components = clone(document.components ?? {})
const sourceSchemas = getDocumentSchemas(document)
components.schemas = selectReferencedSchemas(sourceSchemas, clonedPaths)
return {
...clone(metadata),
components,
paths: clonedPaths,
} satisfies SwaggerDocument
}
@@ -0,0 +1,237 @@
import {
existsSync,
globSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vite-plus/test'
import { normalizeApiSwagger } from './api-documents.ts'
import {
renderCompatibilityTypes,
selectCommunityDocument,
} from './scripts/export-compatibility-types.ts'
import { generateCompatibilitySources } from './scripts/generate-compatibility-sources.ts'
const root = import.meta.dirname
describe('compatible contracts exports', () => {
it('keeps exact backend origins on renamed models and every operation envelope', async () => {
const file = resolve(root, 'backend (models)*/models.py')
const source = { file, line: 17 }
const document = normalizeApiSwagger({
openapi: '3.1.0',
info: { title: 'Navigation', version: '1' },
paths: {
'/pets': {
post: {
'x-source-location': { file, line: 42 },
requestBody: {
content: {
'application/json': { schema: { $ref: '#/components/schemas/pet-model' } },
},
},
responses: {
200: {
content: {
'application/json': { schema: { $ref: '#/components/schemas/pet-model' } },
},
},
400: { content: { 'application/json': { schema: { type: 'string' } } } },
},
},
},
},
components: {
schemas: {
'pet-model': { type: 'object', 'x-source-location': source },
Missing: { type: 'string' },
Relative: { type: 'string', 'x-source-location': { file: 'models.py', line: 1 } },
Invalid: { type: 'string', 'x-source-location': { file, line: 0 } },
},
},
})
const output = await renderCompatibilityTypes(document)
const href = pathToFileURL(file)
.href.replaceAll('*', '%2A')
.replaceAll('(', '%28')
.replaceAll(')', '%29')
expect(output).toContain(
`/** [Open backend implementation](${href}#L17) */\nexport type PetModel`,
)
for (const suffix of ['Data', 'Responses', 'Response', 'Errors', 'Error'])
expect(output).toContain(
`/** [Open backend implementation](${href}#L42) */\nexport type PostPets${suffix} =`,
)
expect(output.match(/Open backend implementation/g)).toHaveLength(6)
})
it('generates subpath entries, preserves no-op mtimes and removes obsolete generated files', async () => {
const directory = mkdtempSync(resolve(tmpdir(), 'dify-contracts-descriptors-'))
try {
const generated = resolve(directory, 'generated/api/console/new-feature')
const descriptor = resolve(directory, 'mapper/console/new-feature.openapi-types')
const declaration = resolve(directory, 'mapper/console/new-feature.d.ts')
mkdirSync(generated, { recursive: true })
writeFileSync(resolve(generated, 'types.gen.ts'), 'export type NewFeature = string')
await generateCompatibilitySources(directory, resolve(directory, 'mapper'))
expect(JSON.parse(readFileSync(descriptor, 'utf8')).args[0]).toBe('api/console/new-feature')
expect(readFileSync(declaration, 'utf8')).toContain(
"export type * from './new-feature.openapi-types'",
)
const files = [
descriptor,
resolve(directory, 'mapper/community.source.json'),
resolve(directory, 'mapper/enterprise.source.json'),
declaration,
]
const timestamps = files.map((file) => statSync(file).mtimeMs)
expect(readFileSync(files[1]!, 'utf8')).toContain(
'"command": ["node", "packages/contracts/scripts/export-compatibility-types.ts"]',
)
await generateCompatibilitySources(directory, resolve(directory, 'mapper'))
expect(files.map((file) => statSync(file).mtimeMs)).toEqual(timestamps)
rmSync(resolve(generated, 'types.gen.ts'))
await generateCompatibilitySources(directory, resolve(directory, 'mapper'))
expect(existsSync(descriptor)).toBe(false)
expect(existsSync(declaration)).toBe(false)
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
it('exposes all generated types through mapper subpaths with shared descriptors', () => {
const mapper = resolve(root, '../openapi-content-mapper')
const pkg = JSON.parse(readFileSync(resolve(mapper, 'package.json'), 'utf8'))
const legacy = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
expect(Object.keys(legacy.exports)).toEqual([
'./marketplace',
'./console',
'./api/*',
'./enterprise/*',
'./enterprise-app-deploy/*',
])
const modules = globSync('generated/**/types.gen.ts', { cwd: root })
expect(modules.length).toBeGreaterThan(50)
for (const file of modules) {
const module = file.slice('generated/'.length, -'/types.gen.ts'.length)
const subpath = module.replace(/^api\//, '')
const key = subpath.startsWith('console/') ? './console/*' : `./${subpath}`
const entry = pkg.exports[key]
expect(Object.keys(entry)).toEqual(['types'])
const declarationPath = resolve(
mapper,
entry.types.replace('*', subpath.slice('console/'.length)),
)
expect(declarationPath).toMatch(/\.d\.ts$/)
const descriptorPath = declarationPath.replace(/\.d\.ts$/, '.openapi-types')
expect(readFileSync(declarationPath, 'utf8')).toContain(
`export type * from './${subpath.split('/').at(-1)}.openapi-types'`,
)
const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8'))
expect(Object.keys(descriptor)).toEqual(['extends', 'args'])
expect(descriptor.args).toEqual([module])
const base = JSON.parse(
readFileSync(resolve(descriptorPath, '..', descriptor.extends), 'utf8'),
)
expect(base.command).toEqual([
'node',
'packages/contracts/scripts/export-compatibility-types.ts',
])
// Repository-specific exports must not leak broken paths into the public SDK tarball.
expect(pkg.publishConfig.exports[key]).toBeUndefined()
}
})
it('automatically exports new models, legacy envelopes, status maps, unions and recursive schemas', async () => {
const reference = { $ref: '#/components/schemas/Tree' }
const document = normalizeApiSwagger({
openapi: '3.1.0',
info: { title: 'Compatibility', version: '1' },
paths: {
'/trees/{id}': {
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
post: {
requestBody: { required: true, content: { 'application/json': { schema: reference } } },
responses: {
201: { content: { 'application/json': { schema: reference } } },
204: { description: 'Empty' },
409: {
content: { 'application/json': { schema: { type: 'string', const: 'conflict' } } },
},
default: { content: { 'application/json': { schema: { type: 'string' } } } },
},
},
},
},
components: {
schemas: {
Tree: {
type: 'object',
required: ['children'],
properties: { children: { type: 'array', items: reference } },
},
},
},
})
const output = await renderCompatibilityTypes(document)
for (const name of [
'ClientOptions',
'Tree',
'PostTreesByIdData',
'PostTreesByIdResponses',
'PostTreesByIdResponse',
'PostTreesByIdErrors',
'PostTreesByIdError',
])
expect(output).toContain(`export type ${name} =`)
expect(output).toMatch(/path: \{\s*id: string;/)
expect(output).toContain("url: '/trees/{id}'")
expect(output).toContain('201: Tree')
expect(output).toContain('204: void')
expect(output).toContain("409: 'conflict'")
expect(output).toContain('default: string')
expect(output).toContain('children: Array<Tree>')
expect(existsSync(resolve(root, '.compatibility-output'))).toBe(false)
})
it('shares Console normalization, FastOpenAPI merge and module splitting with code generation', () => {
const documents = {
'console-openapi.json': {
openapi: '3.1.0',
paths: { '/pets': { get: { responses: { 200: { description: 'Opaque' } } } } },
},
'fastopenapi-console-openapi.json': {
openapi: '3.1.0',
paths: {
'/console/api/knowledge-fs/health': {
get: { responses: { 204: { description: 'Healthy' } } },
},
},
},
}
const pets = selectCommunityDocument('api/console/pets', documents)
expect(Object.keys(pets.paths!)).toEqual(['/pets'])
expect(pets.paths!['/pets']!.get).toMatchObject({
operationId: 'getPets',
responses: {
200: {
content: {
'application/json': { schema: { type: 'object', additionalProperties: true } },
},
},
},
})
const health = selectCommunityDocument('api/console/knowledge-fs', documents)
expect(Object.keys(health.paths!)).toEqual(['/knowledge-fs/health'])
expect(documents['console-openapi.json'].paths['/pets'].get).not.toHaveProperty('operationId')
expect(() => selectCommunityDocument('api/console/missing', documents)).toThrow(
'No Console operations',
)
})
})
+26 -461
View File
@@ -1,43 +1,27 @@
import type { DefinePlugin, UserConfig } from '@hey-api/openapi-ts'
import type { ApiSpec, SwaggerDocument, SwaggerOperation, SwaggerSchema } from './api-documents.ts'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { $, defineConfig, definePluginConfig } from '@hey-api/openapi-ts'
import ts from 'typescript'
type JsonObject = Record<string, unknown>
type SwaggerSchema = JsonObject & {
$ref?: string
}
type OpenApiMediaType = JsonObject & {
schema?: SwaggerSchema
}
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
type OpenApiComponents = JsonObject & {
schemas?: Record<string, SwaggerSchema>
}
type SwaggerOperation = JsonObject & {
operationId?: string
responses?: Record<string, OpenApiResponse>
}
type SwaggerDocument = JsonObject & {
components?: OpenApiComponents
openapi?: string
paths?: Record<string, Record<string, unknown>>
}
type ApiSpec = {
filename: string
name: string
}
import {
apiSpecs,
cloneDocumentWithPaths,
contractPathSegments,
fastOpenApiConsoleSpecFilename,
getDocumentSchemas,
groupConsolePaths,
isObject,
mergeFastOpenApiConsoleSwagger,
normalizeApiSwagger,
operationMethods,
schemaNameFromRef,
segmentWords,
toCamelCase,
toKebabCase,
} from './api-documents.ts'
import { typescriptPlugin } from './typescript-generation.ts'
type ApiJob = {
document: SwaggerDocument
@@ -51,15 +35,6 @@ type ApiJob = {
}
}
type ApiContractOperation = {
method: string
path: string
}
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const apiOpenApiDir = path.resolve(currentDir, 'openapi')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
const strictZodSchemaNames = new Set([
'AccountProfilePatchPayload',
'Parameters',
@@ -72,68 +47,9 @@ const strictZodSchemaNames = new Set([
])
const pydanticDecimalStringPattern = '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$'
const codegenSafeDecimalStringPattern = '^(?![-+.]*$)[+-]?0*\\d*\\.?\\d*$'
const fastOpenApiConsoleSpecFilename = 'fastopenapi-console-openapi.json'
const fastOpenApiConsolePathPrefix = '/console/api'
const apiSpecs: ApiSpec[] = [
{ filename: 'console-openapi.json', name: 'console' },
{ filename: 'web-openapi.json', name: 'web' },
{ filename: 'service-openapi.json', name: 'service' },
{ filename: 'openapi-openapi.json', name: 'openapi' },
]
const isObject = (value: unknown): value is JsonObject => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const toWords = (value: string) => {
return value
.replace(/[{}]/g, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^a-z0-9]+/i)
.filter(Boolean)
}
const toPascalCase = (words: string[]) => {
return words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('')
}
const toCamelCase = (words: string[]) => {
const pascal = toPascalCase(words)
return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`
}
const toKebabCase = (value: string) => {
return toWords(value).join('-').toLowerCase()
}
const segmentWords = (segment: string) => {
if (segment.startsWith('{') && segment.endsWith('}')) return ['by', ...toWords(segment)]
return toWords(segment)
}
// Split on `:` too so custom methods nest as their own node (apps.byAppId.run), not apps.appIdRun.
const routeNamingSegments = (routePath: string) => routePath.split(/[/:]/).filter(Boolean)
const routeWords = (routePath: string) => {
return routeNamingSegments(routePath).flatMap(segmentWords)
}
const operationId = (method: string, routePath: string) => {
return toCamelCase([
method,
...(routeWords(routePath).length > 0 ? routeWords(routePath) : ['root']),
])
}
const contractPathSegments = (operation: ApiContractOperation) => {
const segments = routeNamingSegments(operation.path).map((segment) =>
toCamelCase(segmentWords(segment)),
)
return [...(segments.length > 0 ? segments : ['root']), operation.method.toLowerCase()]
}
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const apiOpenApiDir = path.resolve(currentDir, 'openapi')
const readApiSwagger = (filename: string): SwaggerDocument => {
const specPath = path.join(apiOpenApiDir, filename)
@@ -151,349 +67,6 @@ const readApiSwagger = (filename: string): SwaggerDocument => {
return rawSpec as SwaggerDocument
}
const clone = <T>(value: T): T => {
return JSON.parse(JSON.stringify(value)) as T
}
const componentSchemaRefPrefix = '#/components/schemas/'
const schemaNameFromRef = (ref: string) => {
if (ref.startsWith(componentSchemaRefPrefix)) return ref.slice(componentSchemaRefPrefix.length)
return undefined
}
const getDocumentSchemas = (document: SwaggerDocument) => {
const components = (document.components ??= {})
return (components.schemas ??= {})
}
const collectSchemaRefs = (value: unknown, refs: Set<string>, visited = new WeakSet<object>()) => {
if (!value || typeof value !== 'object') return
if (visited.has(value)) return
visited.add(value)
if (Array.isArray(value)) {
value.forEach((item) => collectSchemaRefs(item, refs, visited))
return
}
const objectValue = value as JsonObject
const ref = objectValue.$ref
if (typeof ref === 'string') {
const refName = schemaNameFromRef(ref)
if (refName) refs.add(refName)
}
Object.values(objectValue).forEach((item) => collectSchemaRefs(item, refs, visited))
}
const addOperationIds = (document: SwaggerDocument) => {
for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation)) continue
const swaggerOperation = operation as SwaggerOperation
swaggerOperation.operationId = operationId(method, routePath)
}
}
}
const normalizeOpaqueContractResponses = (document: SwaggerDocument) => {
// This runs before contract filtering. Flask-RESTX often emits plain success responses
// without a body schema; give those routes an opaque output so they stay in oRPC.
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
for (const [status, response] of Object.entries(swaggerOperation.responses ?? {})) {
// Ignore non-2xx or 204 or those w/o a response field
if (!/^2\d\d$/.test(status) || status === '204' || !isObject(response)) continue
const content = response.content
if (!isObject(content) || Object.keys(content).length === 0) {
// No response specification, fill a dummy opaque resp
response.content = {
'application/json': {
schema: {
additionalProperties: true,
type: 'object',
},
},
}
continue
}
for (const [mediaType, media] of Object.entries(content)) {
if (mediaType !== 'application/json' && mediaType !== 'text/event-stream') continue
if (!isObject(media) || isObject(media.schema)) continue
// JSON/SSE media without a schema traps heyapi. Patch only that media entry so
// sibling binary media keeps heyapi's Blob | File inference.
// Still a dummy opaque resp
media.schema = {
additionalProperties: true,
type: 'object',
}
}
}
}
}
}
const hasSuccessResponse = (operation: SwaggerOperation) => {
return Object.entries(operation.responses ?? {}).some(([status, response]) => {
if (!/^2\d\d$/.test(status)) return false
if (!isObject(response)) return false
const content = (response as JsonObject).content
// 204 No Content is a valid success response without a body
if (!isObject(content) || Object.keys(content).length === 0) return status === '204'
return true
})
}
const filterContractOperations = (document: SwaggerDocument) => {
for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation)) continue
if (!hasSuccessResponse(operation as SwaggerOperation)) delete pathItem[method]
}
const hasOperations = Object.entries(pathItem).some(
([method, operation]) => operationMethods.has(method) && isObject(operation),
)
if (!hasOperations) delete document.paths?.[routePath]
}
}
const includeMultipartRequestSchemas = (document: SwaggerDocument) => {
for (const pathItem of Object.values(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation) || !isObject(operation.requestBody))
continue
const content = operation.requestBody.content
if (!isObject(content)) continue
const json = content['application/json']
const multipart = content['multipart/form-data']
if (
!isObject(json) ||
!isObject(json.schema) ||
!isObject(multipart) ||
!isObject(multipart.schema)
)
continue
// hey-api selects JSON for mixed request media; retain the multipart shape
// so the generated client can serialize File values as FormData.
json.schema = { anyOf: [json.schema, multipart.schema] }
}
}
}
const includeNonJsonResponseSchemas = (document: SwaggerDocument) => {
for (const pathItem of Object.values(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation)) continue
for (const [status, response] of Object.entries(
(operation as SwaggerOperation).responses ?? {},
)) {
if (!/^2\d\d$/.test(status) || !isObject(response) || !isObject(response.content)) continue
const jsonMedia = response.content['application/json']
if (!isObject(jsonMedia) || !isObject(jsonMedia.schema)) continue
const alternatives: SwaggerSchema[] = []
for (const [mediaType, media] of Object.entries(response.content)) {
if (mediaType === 'application/json' || !isObject(media) || !isObject(media.schema))
continue
if (mediaType === 'text/event-stream' || media.schema.format === 'binary')
alternatives.push(media.schema)
}
if (alternatives.length === 0) continue
// hey-api selects the JSON schema when one status advertises multiple
// response media types. Preserve SSE and binary transports in the generated
// TypeScript and Zod contracts by making that selected schema a union.
jsonMedia.schema = {
anyOf: [jsonMedia.schema, ...alternatives],
}
}
}
}
}
const normalizeCodegenSchemas = (document: SwaggerDocument) => {
const visitedSchemas = new WeakSet<object>()
const visitSchema = (value: unknown) => {
if (!isObject(value) || visitedSchemas.has(value)) return
visitedSchemas.add(value)
if (value.default === null) delete value.default
// Dify serializes int64 fields as JSON numbers. hey-api maps int64 to
// bigint in Zod, which disagrees with both the wire value and its own
// generated TypeScript type. Keep int64 in the published OpenAPI specs,
// but generate number-based client validators from this in-memory copy.
if (value.type === 'integer' && value.format === 'int64') delete value.format
for (const key of [
'additionalProperties',
'contains',
'else',
'if',
'items',
'not',
'propertyNames',
'then',
'unevaluatedItems',
'unevaluatedProperties',
]) {
visitSchema(value[key])
}
for (const key of ['allOf', 'anyOf', 'oneOf', 'prefixItems']) {
const schemas = value[key]
if (Array.isArray(schemas)) schemas.forEach(visitSchema)
}
for (const key of [
'$defs',
'definitions',
'dependentSchemas',
'patternProperties',
'properties',
]) {
const schemas = value[key]
if (isObject(schemas)) Object.values(schemas).forEach(visitSchema)
}
}
const visitedDocumentObjects = new WeakSet<object>()
const findSchemas = (value: unknown, parentKey?: string) => {
if (!value || typeof value !== 'object' || visitedDocumentObjects.has(value)) return
visitedDocumentObjects.add(value)
if (parentKey === 'schema') {
visitSchema(value)
return
}
if (parentKey === 'schemas' && isObject(value)) {
Object.values(value).forEach(visitSchema)
return
}
if (parentKey === 'example' || parentKey === 'examples') return
if (Array.isArray(value)) {
value.forEach((item) => findSchemas(item))
return
}
Object.entries(value).forEach(([key, item]) => findSchemas(item, key))
}
findSchemas(document)
}
const normalizeApiSwagger = (document: SwaggerDocument) => {
normalizeOpaqueContractResponses(document)
filterContractOperations(document)
addOperationIds(document)
includeMultipartRequestSchemas(document)
includeNonJsonResponseSchemas(document)
// OpenAPI defaults describe server behavior. Keep them in the exported specs,
// but do not let Zod synthesize omitted transport fields during client-side
// request or response validation. Non-null defaults remain useful for query
// parameter ergonomics and preserve the existing generated contract behavior.
normalizeCodegenSchemas(document)
return document
}
const mergeFastOpenApiConsoleSwagger = (document: SwaggerDocument) => {
const fastOpenApiDocument = readApiSwagger(fastOpenApiConsoleSpecFilename)
const targetPaths = (document.paths ??= {})
for (const [routePath, pathItem] of Object.entries(fastOpenApiDocument.paths ?? {})) {
const contractPath = routePath.startsWith(fastOpenApiConsolePathPrefix)
? routePath.slice(fastOpenApiConsolePathPrefix.length) || '/'
: routePath
if (targetPaths[contractPath])
throw new Error(`Duplicate console API path after FastOpenAPI merge: ${contractPath}`)
targetPaths[contractPath] = pathItem
}
const targetSchemas = getDocumentSchemas(document)
const sourceSchemas = getDocumentSchemas(fastOpenApiDocument)
for (const [schemaName, schema] of Object.entries(sourceSchemas)) {
if (targetSchemas[schemaName])
throw new Error(`Duplicate console API schema after FastOpenAPI merge: ${schemaName}`)
targetSchemas[schemaName] = schema
}
return document
}
const topLevelPathSegment = (routePath: string) => {
return routePath.split('/').filter(Boolean)[0] ?? 'root'
}
const selectReferencedSchemas = (
schemas: Record<string, SwaggerSchema>,
paths: Record<string, Record<string, unknown>>,
) => {
const selectedSchemas: Record<string, SwaggerSchema> = {}
const pendingRefs = new Set<string>()
collectSchemaRefs(paths, pendingRefs)
while (pendingRefs.size > 0) {
const refName = pendingRefs.values().next().value
if (!refName) break
pendingRefs.delete(refName)
if (selectedSchemas[refName]) continue
const schema = schemas[refName]
if (!schema) throw new Error(`Missing referenced schema: ${refName}`)
selectedSchemas[refName] = schema
const nestedRefs = new Set<string>()
collectSchemaRefs(selectedSchemas[refName], nestedRefs)
for (const nestedRef of nestedRefs) {
if (!selectedSchemas[nestedRef]) pendingRefs.add(nestedRef)
}
}
return selectedSchemas
}
const cloneDocumentWithPaths = (
document: SwaggerDocument,
paths: Record<string, Record<string, unknown>>,
) => {
const { components: _components, paths: _paths, ...metadata } = document
const clonedPaths = clone(paths)
const components = clone(document.components ?? {})
const sourceSchemas = getDocumentSchemas(document)
components.schemas = selectReferencedSchemas(sourceSchemas, clonedPaths)
return {
...clone(metadata),
components,
paths: clonedPaths,
} satisfies SwaggerDocument
}
const consoleContractEntryContent = (segments: string[]) => {
const contracts = segments.map((segment) => {
return {
@@ -587,15 +160,7 @@ const createConsoleContractEntryJob = (document: SwaggerDocument, segments: stri
}
const splitConsoleDocument = (document: SwaggerDocument) => {
const pathsBySegment = new Map<string, Record<string, Record<string, unknown>>>()
for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) {
const segment = topLevelPathSegment(routePath)
const paths = pathsBySegment.get(segment) ?? {}
paths[routePath] = pathItem
pathsBySegment.set(segment, paths)
}
const pathsBySegment = groupConsolePaths(document)
const segments = [...pathsBySegment.keys()].sort((left, right) => left.localeCompare(right))
const jobs = segments.map((segment): ApiJob => ({
document: cloneDocumentWithPaths(document, pathsBySegment.get(segment) ?? {}),
@@ -608,7 +173,10 @@ const splitConsoleDocument = (document: SwaggerDocument) => {
const createApiJobs = (spec: ApiSpec): ApiJob[] => {
const document = normalizeApiSwagger(
spec.name === 'console'
? mergeFastOpenApiConsoleSwagger(readApiSwagger(spec.filename))
? mergeFastOpenApiConsoleSwagger(
readApiSwagger(spec.filename),
readApiSwagger(fastOpenApiConsoleSpecFilename),
)
: readApiSwagger(spec.filename),
)
@@ -717,10 +285,7 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
...(job.source ? { source: job.source } : {}),
},
plugins: job.plugins ?? [
{
comments: false,
name: '@hey-api/typescript',
},
typescriptPlugin(),
{
name: 'zod',
'~resolvers': {
@@ -3,6 +3,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@hey-api/openapi-ts'
import { loadOpenApiYaml } from './openapi-yaml.ts'
import { stripEnterpriseSchemaNamePrefix, typescriptPlugin } from './typescript-generation.ts'
type JsonObject = Record<string, unknown>
@@ -78,14 +79,6 @@ const stripConsoleApiPrefix = (routePath: string) => {
return routePath
}
const stripSchemaNamePrefix = (schemaName: string) => {
return schemaName
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
.replace(/^pagination\./, '')
}
const contractTagSegment = (tag?: string) => {
if (tag === 'ConsoleDeploymentService') return 'DeploymentService'
if (tag === 'ConsoleAccessService') return 'AccessService'
@@ -173,7 +166,9 @@ const findSchemaEntry = (
schemas: Record<string, OpenApiSchema>,
schemaName: string,
): [string, OpenApiSchema] | undefined => {
return Object.entries(schemas).find(([name]) => stripSchemaNamePrefix(name) === schemaName)
return Object.entries(schemas).find(
([name]) => stripEnterpriseSchemaNamePrefix(name) === schemaName,
)
}
const enumValuesKey = (values: unknown[]) => JSON.stringify(values)
@@ -202,7 +197,7 @@ const enumSchemaKey = (
if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey)
return existingEnumEntry[0]
return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}`
return `${stripEnterpriseSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}`
}
const promoteInlineEnumSchema = (
@@ -309,17 +304,11 @@ export default defineConfig({
},
parser: {
transforms: {
schemaName: stripSchemaNamePrefix,
schemaName: stripEnterpriseSchemaNamePrefix,
},
},
plugins: [
{
name: '@hey-api/typescript',
comments: false,
enums: {
mode: 'javascript',
},
},
typescriptPlugin(true),
'zod',
{
name: 'orpc',
@@ -3,6 +3,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@hey-api/openapi-ts'
import { loadOpenApiYaml } from './openapi-yaml.ts'
import { stripEnterpriseSchemaNamePrefix, typescriptPlugin } from './typescript-generation.ts'
type JsonObject = Record<string, unknown>
@@ -78,14 +79,6 @@ const stripConsoleApiPrefix = (routePath: string) => {
return routePath
}
const stripSchemaNamePrefix = (schemaName: string) => {
return schemaName
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
.replace(/^pagination\./, '')
}
const contractTagSegment = (tag?: string) => {
if (tag === 'EnterpriseAppDeployConsole') return 'AppDeploy'
@@ -172,7 +165,9 @@ const findSchemaEntry = (
schemas: Record<string, OpenApiSchema>,
schemaName: string,
): [string, OpenApiSchema] | undefined => {
return Object.entries(schemas).find(([name]) => stripSchemaNamePrefix(name) === schemaName)
return Object.entries(schemas).find(
([name]) => stripEnterpriseSchemaNamePrefix(name) === schemaName,
)
}
const enumValuesKey = (values: unknown[]) => JSON.stringify(values)
@@ -201,7 +196,7 @@ const enumSchemaKey = (
if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey)
return existingEnumEntry[0]
return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}`
return `${stripEnterpriseSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}`
}
const promoteInlineEnumSchema = (
@@ -308,17 +303,11 @@ export default defineConfig({
},
parser: {
transforms: {
schemaName: stripSchemaNamePrefix,
schemaName: stripEnterpriseSchemaNamePrefix,
},
},
plugins: [
{
name: '@hey-api/typescript',
comments: false,
enums: {
mode: 'javascript',
},
},
typescriptPlugin(true),
'zod',
{
name: 'orpc',
+5 -4
View File
@@ -26,12 +26,13 @@
}
},
"scripts": {
"gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"if (process.env.CI) fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && node scripts/add-orpc-error-maps.ts && vp fmt generated/api",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts && node scripts/snapshot-enterprise-openapi.ts enterprise",
"gen-enterprise-app-deploy-contract": "openapi-ts -f openapi-ts.enterprise-app-deploy.config.ts && node scripts/snapshot-enterprise-openapi.ts enterprise-app-deploy",
"gen-api-contract": "uv run --frozen --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --frozen --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"if (process.env.CI) fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && node scripts/add-orpc-error-maps.ts && vp fmt generated/api && node scripts/generate-compatibility-sources.ts",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts && node scripts/snapshot-enterprise-openapi.ts enterprise && node scripts/generate-compatibility-sources.ts",
"gen-enterprise-app-deploy-contract": "openapi-ts -f openapi-ts.enterprise-app-deploy.config.ts && node scripts/snapshot-enterprise-openapi.ts enterprise-app-deploy && node scripts/generate-compatibility-sources.ts",
"test": "vp test",
"type-check": "tsrs --noEmit",
"compose:console": "node scripts/compose-console-openapi.mjs"
"compose:console": "node scripts/compose-console-openapi.mjs",
"gen-compatibility-sources": "node scripts/generate-compatibility-sources.ts"
},
"dependencies": {
"@orpc/contract": "catalog:",
@@ -64,12 +64,16 @@ export function composeConsoleDocuments(sources) {
export function loadConsoleDocument(root = resolve(import.meta.dirname, '..')) {
const community = JSON.parse(
execFileSync('uv', ['run', '--project', 'api', 'api/dev/cached_console_openapi.py'], {
cwd: resolve(root, '../..'),
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
timeout: 100000,
}),
execFileSync(
'uv',
['run', '--frozen', '--project', 'api', 'api/dev/cached_console_openapi.py'],
{
cwd: resolve(root, '../..'),
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
timeout: 100000,
},
),
)
const snapshots = [
['enterprise', 'generated/enterprise/openapi.gen.json'],
@@ -0,0 +1,176 @@
import type { UserConfig } from '@hey-api/openapi-ts'
import type { SwaggerDocument } from '../api-documents.ts'
import { execFileSync } from 'node:child_process'
import { readFileSync, statSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { createClient } from '@hey-api/openapi-ts'
import {
cloneDocumentWithPaths,
groupConsolePaths,
mergeFastOpenApiConsoleSwagger,
normalizeApiSwagger,
toKebabCase,
} from '../api-documents.ts'
import { stripEnterpriseSchemaNamePrefix, typescriptPlugin } from '../typescript-generation.ts'
const root = resolve(import.meta.dirname, '..')
const repo = resolve(root, '../..')
/** Use the same TypeScript plugin as normal generation, without writing any artifacts. */
export async function renderCompatibilityTypes(
document: SwaggerDocument,
enterprise = false,
): Promise<string> {
const config: UserConfig = {
input: document,
dryRun: true,
logs: { level: 'silent', file: false },
output: {
path: resolve(root, '.compatibility-output'),
// Hey API's clean step runs even in dry-run mode.
clean: false,
entryFile: false,
fileName: { suffix: '.gen' },
},
...(enterprise
? {
parser: {
transforms: {
schemaName: stripEnterpriseSchemaNamePrefix,
},
},
}
: {}),
plugins: [typescriptPlugin(enterprise)],
}
const [context] = await createClient(config)
const files = context?.gen.render() ?? []
const file = files.find((file) => file.path.endsWith('/types.gen.ts'))
if (!file || files.length !== 1)
throw new Error('Expected one generated compatibility types module')
const origins = new Map<string, string>()
for (const symbol of context!.gen.symbols.registered()) {
const path = symbol.meta?.path
if (!Array.isArray(path)) continue
let node: unknown = document
for (const part of path) {
node =
node && typeof node === 'object'
? (node as Record<string, unknown>)[String(part)]
: undefined
}
if (!node || typeof node !== 'object') continue
const source = (node as Record<string, unknown>)['x-source-location'] as
| { file?: unknown; line?: unknown }
| undefined
if (
!source ||
typeof source.file !== 'string' ||
!isAbsolute(source.file) ||
typeof source.line !== 'number' ||
!Number.isSafeInteger(source.line) ||
source.line < 1
)
continue
const url = pathToFileURL(source.file)
url.hash = `L${source.line}`
const href = url.href.replaceAll('*', '%2A').replaceAll('(', '%28').replaceAll(')', '%29')
origins.set(symbol.finalName, `/** [Open backend implementation](${href}) */\n`)
}
// Use the generator's resolved symbols, including renamed schemas and envelopes.
// Normal contracts generation stays unchanged; only virtual exports get links.
return file.content.replace(
/^export (?:type|interface|const|enum) (\w+)/gm,
(declaration, name: string) => `${origins.get(name) ?? ''}${declaration}`,
)
}
export function selectCommunityDocument(
module: string,
documents: Record<string, SwaggerDocument>,
): SwaggerDocument {
const [, surface, segment] = module.split('/')
const input = documents[`${surface}-openapi.json`]
if (!input) throw new Error(`Unknown contracts surface: ${surface}`)
const document = normalizeApiSwagger(
surface === 'console'
? mergeFastOpenApiConsoleSwagger(
structuredClone(input),
structuredClone(documents['fastopenapi-console-openapi.json']!),
)
: structuredClone(input),
)
if (surface !== 'console') return document
const paths: NonNullable<SwaggerDocument['paths']> = {}
for (const [name, group] of groupConsolePaths(document)) {
if (toKebabCase(name) === segment) Object.assign(paths, group)
}
if (!Object.keys(paths).length) throw new Error(`No Console operations for ${module}`)
return cloneDocumentWithPaths(document, paths)
}
function exportPython(args: string[]): Record<string, SwaggerDocument> {
return JSON.parse(
execFileSync('uv', ['run', '--frozen', '--project', 'api', ...args], {
cwd: repo,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
timeout: 90000,
stdio: ['ignore', 'pipe', 'pipe'],
}),
)
}
export function loadCommunityDocuments(consoleOnly: boolean): Record<string, SwaggerDocument> {
if (consoleOnly) {
const exported = exportPython(['api/dev/cached_console_openapi.py'])
return {
'console-openapi.json': exported.console!,
'fastopenapi-console-openapi.json': exported.fast!,
}
}
return exportPython([
'python',
'-c',
[
'import contextlib, json, sys',
'sys.path.insert(0, "api")',
'with contextlib.redirect_stdout(sys.stderr):',
' from dev.cached_console_openapi import source_imports',
' source_imports()',
' from dev.export_console_openapi import export_all_with_sources',
' documents = export_all_with_sources()',
'json.dump(documents, sys.stdout)',
].join('\n'),
])
}
export async function exportCompatibilityTypes(module: string): Promise<string> {
// Only existing published modules are accepted; module names cannot escape generated/.
if (
!/^(?:api\/(?:console\/[a-z0-9-]+|web|service|openapi)|enterprise(?:-app-deploy)?)$/.test(
module,
)
)
throw new Error(`Invalid contracts module: ${module}`)
const original = resolve(root, 'generated', module, 'types.gen.ts')
if (!statSync(original).isFile()) throw new Error(`Missing contracts module: ${module}`)
if (module.startsWith('enterprise')) {
const document: SwaggerDocument = JSON.parse(
readFileSync(resolve(root, 'generated', module, 'openapi.gen.json'), 'utf8'),
)
const generated = await renderCompatibilityTypes(document, true)
// Retained Console snapshots omit non-Console models. Preserve their existing
// exports (including enum values) instead of silently dropping public names.
return `${generated}\nexport * from ${JSON.stringify(original.replace(/\.ts$/, '.js'))};\n`
}
const documents = loadCommunityDocuments(module.startsWith('api/console/'))
return renderCompatibilityTypes(selectCommunityDocument(module, documents))
}
if (import.meta.main) {
const module = process.argv[2]
if (!module) throw new Error('Expected a contracts module, such as api/console/datasets')
process.stdout.write(JSON.stringify({ typescript: await exportCompatibilityTypes(module) }))
}
@@ -0,0 +1,105 @@
import { existsSync, globSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { basename, dirname, relative, resolve } from 'node:path'
import { format } from 'vite-plus/fmt'
const declarationHeader = '// Generated by @dify/contracts gen-compatibility-sources.\n'
function writeIfChanged(file: string, content: string) {
mkdirSync(dirname(file), { recursive: true })
const existing = existsSync(file) ? readFileSync(file, 'utf8') : undefined
// No-op generation must not restart the language server.
if (existing !== content) writeFileSync(file, content)
}
async function writeDescriptor(file: string, value: unknown) {
let content = `${JSON.stringify(value, null, 2)}\n`
if (file.endsWith('.json')) {
const formatted = await format(file, content)
if (formatted.errors.length) throw new Error(`Failed to format ${file}`)
content = formatted.code
}
writeIfChanged(file, content)
}
/** Thin mapper subpaths share one exporter configuration per backend source. */
export async function generateCompatibilitySources(
root = resolve(import.meta.dirname, '..'),
output = resolve(root, '../openapi-content-mapper/contracts'),
) {
const repo = resolve(root, '../..')
const sharedSources = {
community: resolve(output, 'community.source.json'),
enterprise: resolve(output, 'enterprise.source.json'),
}
for (const [name, file] of Object.entries(sharedSources)) {
const enterprise = name === 'enterprise'
await writeDescriptor(file, {
command: ['node', 'packages/contracts/scripts/export-compatibility-types.ts'],
cwd: relative(output, repo),
inputs: [
'packages/contracts/api-documents.ts',
'packages/contracts/typescript-generation.ts',
'packages/contracts/scripts/export-compatibility-types.ts',
'packages/contracts/package.json',
'pnpm-lock.yaml',
...(enterprise
? [
'packages/contracts/generated/enterprise*/types.gen.ts',
'packages/contracts/generated/enterprise*/openapi.gen.json',
]
: [
'packages/contracts/generated/api/**/types.gen.ts',
'api/**/*.py',
'dify-agent/src/**/*.py',
'dify-agent/pyproject.toml',
'api/pyproject.toml',
'api/uv.lock',
]),
],
...(enterprise
? {}
: {
env: ['UV_PROJECT_ENVIRONMENT'],
exclude: ['api/tests/**'],
optionalInputs: ['.env'],
}),
timeoutMs: 120000,
})
}
const modules = globSync('generated/**/types.gen.ts', { cwd: root }).sort()
const expected = new Set<string>()
for (const entry of modules) {
const module = entry.slice('generated/'.length, -'/types.gen.ts'.length)
const subpath = module.replace(/^api\//, '')
const file = resolve(output, `${subpath}.openapi-types`)
expected.add(file)
const declaration = resolve(output, `${subpath}.d.ts`)
expected.add(declaration)
// A native declaration entry avoids TS 7's missing-declaration hint for
// package exports targeting custom extensions. Types still come from the mapper.
writeIfChanged(declaration, `${declarationHeader}export type * from './${basename(file)}'\n`)
await writeDescriptor(file, {
extends: relative(
dirname(file),
sharedSources[module.startsWith('enterprise') ? 'enterprise' : 'community'],
),
args: [module],
})
}
for (const entry of globSync('**/*.openapi-types', { cwd: output })) {
const file = resolve(output, entry)
if (!expected.has(file)) {
const descriptor = JSON.parse(readFileSync(file, 'utf8'))
if (Object.values(sharedSources).includes(resolve(dirname(file), descriptor.extends ?? '')))
rmSync(file)
}
}
for (const entry of globSync('**/*.d.ts', { cwd: output })) {
const file = resolve(output, entry)
if (!expected.has(file) && readFileSync(file, 'utf8').startsWith(declarationHeader))
rmSync(file)
}
return modules
}
if (import.meta.main) await generateCompatibilitySources()
@@ -0,0 +1,13 @@
/** Shared by contract generation and the mapper's in-memory type exporter. */
export const typescriptPlugin = (enterprise = false) => ({
name: '@hey-api/typescript' as const,
comments: false,
...(enterprise ? { enums: { mode: 'javascript' as const } } : {}),
})
export const stripEnterpriseSchemaNamePrefix = (name: string) =>
name
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
.replace(/^pagination\./, '')
+186 -2
View File
@@ -196,7 +196,9 @@ model dependencies preloaded. Changing a preloaded dependency, adding/removing a
Python file, changing `.env`, or changing the environment invalidates that state.
Python bytecode caches are bypassed to handle rapid same-size edits. Export errors
are surfaced rather than serving the old contract. Refreshes interrupted by more
source edits are retried. Backend tests are excluded from Console source inputs.
source edits are retried. Console and Community type descriptors track Python
sources in `api/` and the editable `dify-agent/src/` dependency, together with their
package configuration. Backend tests are excluded from Console source inputs.
Native threads are checked before forking; unsafe snapshots and platforms without
fork use a fresh-process export instead. Those refreshes still incur cold startup.
@@ -236,7 +238,7 @@ live API schemas and retained Enterprise snapshots are unchanged.
## API and transport
Documents export only `api` and `query`; operation IDs are not exports. `/pets/{petId}` becomes `pets.byPetId`, hyphenated segments become camelCase and HTTP methods are leaf functions. Colon actions such as `/chat:run` become `chat.run`. `key` is reserved as a route segment for query helpers. Unresolvable member collisions fail during compilation.
Documents export `api` and `query` at runtime, plus the type-only `Schemas`, `Inputs` and `Outputs` maps; operation IDs are not exports. `/pets/{petId}` becomes `pets.byPetId`, hyphenated segments become camelCase and HTTP methods are leaf functions. Colon actions such as `/chat:run` become `chat.run`. `key` is reserved as a route segment for query helpers. Unresolvable member collisions fail during compilation.
An operation may set `x-client-path` to an explicit array such as `["enterprise", "access", "getEnvironmentApi"]` to preserve an existing public API. This replaces its entire client member path, including the method leaf, and also determines its query key. HTTP methods, URLs and per-operation sidecar keys still use the OpenAPI operation. Colliding or reserved member names fail during compilation.
@@ -266,6 +268,174 @@ Wildcard response media follow the most specific matching declaration for the re
With one request media type, `contentType` can be omitted. With multiple media types, only `application/json` is implicit; other alternatives require an explicit matching `contentType`. Multipart accepts flat objects with scalar, Blob/File or repeated array fields, plus explicit JSON part encodings. Fetch supplies its boundary. Optional request bodies may be omitted entirely.
### Reuse model, input and output types
See [Petstore type examples] for reusable aliases and
a legacy request envelope, and [the runnable demo]
for typed HTTP calls. Run `pnpm --filter @dify/openapi-content-mapper type-check`
to check the examples, or `pnpm --filter @dify/openapi-content-mapper demo`
to execute them against a local server started by the demo.
Import types from the same document as the API. This works for YAML, JSON `.openapi`
files and `.openapi-source` command sources, without generating files:
```ts
import type { Schemas, Inputs, Outputs } from './pets.openapi.yaml'
type Pet = Schemas['Pet']
type CreatePetBody = Inputs['pets']['post']['body']
type GetPetInput = Inputs['pets']['byPetId']['get']
type PetList = Outputs['pets']['get']
type ListQuery = NonNullable<Inputs['pets']['get']>['query']
```
`Schemas` contains every `components.schemas` entry under its original name,
including unused and recursive models. Names containing `/` or `~` are accessed
with their original spelling. It describes JSON values: binary-formatted strings
remain `string`. For a multipart body, use `Inputs` to get the appropriate
`Blob`/`File`-compatible fields. Documents without component schemas export an
empty `Schemas` map.
`Inputs` and `Outputs` follow the `api` member paths, including `x-client-path`
overrides. Each input is the operation's first argument, equivalent to
`Parameters<typeof api.pets.get>[0]`; optional inputs include `undefined`. It does
not include the second argument's transport options or context. Each output is
the awaited successful result, equivalent to `Awaited<ReturnType<...>>`, including
`void` for empty responses, `Blob` for binary responses and streams for SSE.
Inline schemas and media-type discrimination are preserved. All three exports
are erased from JavaScript; use `import type` or `export type` to consume them.
### Dify contract type subpaths
Inside the Dify repository, import compatible contract types directly from this
package's subpaths. Existing type names and structures are preserved; only the
module specifier changes:
```ts
import type {
DatasetCreatePayload,
PostDatasetsData,
PostDatasetsResponses,
PostDatasetsResponse,
PostDatasetsErrors,
PostDatasetsError,
} from '@dify/openapi-content-mapper/console/datasets'
const body: DatasetCreatePayload = { name: 'Documentation' }
const request: PostDatasetsData = { url: '/datasets', body }
type Created = PostDatasetsResponses[201]
type Conflict = PostDatasetsErrors[409]
```
Available subpaths are `/console/*`, `/service`, `/web`, `/openapi`, `/enterprise`
and `/enterprise-app-deploy`. They provide models, `ClientOptions`, legacy `*Data`
request envelopes, status-indexed `*Responses` / `*Errors`, and the singular
response/error unions. Same-named types stay isolated in their modules. The
original `@dify/contracts` exports remain unchanged; no `customConditions` or
conditional overrides of those paths are needed.
These subpaths are **type-only**. For runtime enum objects, continue to import the
original contracts module, while the mapper subpath can supply the type:
```ts
import type { AccessMode } from '@dify/openapi-content-mapper/enterprise'
import { AccessMode as AccessModes } from '@dify/contracts/enterprise/types.gen'
const mode: AccessMode = AccessModes.ACCESS_MODE_PRIVATE
```
Enable `.openapi-types` in the consuming project's mapper settings. The mapper
automatically registers the contract sources shipped in its workspace package:
```json
{
"contentMappers": [
{
"package": "@dify/openapi-content-mapper",
"extensions": [".openapi", ".openapi-source", ".openapi-types"]
}
]
}
```
No `options.sources` or `paths` entries are needed for these package subpaths.
Registration watches descriptors and backend inputs without running exporters;
exporters run when their modules are imported. Modules sharing inputs reuse one
filesystem scan per registration. Custom `.openapi-source` and `.openapi-types`
files outside the package's `contracts/` directory still require `options.sources`,
with paths relative to the consuming tsconfig. Explicit sources are combined with
the automatic sources. Run TypeScript 7.1 with `--runExternalCode`, and enable
content mappers in the editor. The [repository compatibility example]
and its tsconfig already use these subpaths:
```sh
pnpm --filter @dify/openapi-content-mapper type-check:contracts
```
The compatibility exporter reuses the same Community document normalization,
Console module splitting and Hey API TypeScript plugin as regular generation.
It exports current Python schemas into memory and runs Hey API with disk output
and cleanup disabled. Request `path`/`url`, status maps, writable variants,
recursive models, binary types and enum naming therefore follow the existing
contracts rules, rather than the mapper RPC `Inputs`/`Outputs` rules. Backend
contract changes still change the inferred types.
Type hovers include **Open backend implementation** links for registered Community
Python models and API request/response/error envelopes. With the local
[VS Code extension], F12 / Cmd+click and Go to
Implementation follow these links, including through import aliases and re-exports.
In the example, `DatasetCreatePayload` opens its Python class and `PostDatasetsData`
opens `DatasetListApi.post`. Synthetic models and snapshots without source metadata
have no backend target; the exporter does not guess by type name.
Enterprise and App Deploy use the retained OpenAPI snapshots. These snapshots omit
some non-Console models; missing exports are re-exported from the existing complete
contracts modules. Those retained types update with regular Enterprise generation,
rather than with unavailable private backend sources.
The three contracts generation commands also regenerate the mapper's thin subpath
selectors and `.d.ts` entry points. To refresh them alone after adding modules, run
`pnpm --filter @dify/contracts gen-compatibility-sources`. Each selector contains
only an `extends` path and module argument; exporter commands and watched inputs
live in two shared source configurations. Each declaration entry contains only an
`export type *` pointing to its selector; model declarations remain virtual and new
names are discovered automatically. No additional OpenAPI snapshots are written.
Package exports resolve through these standard `.d.ts` entries so TypeScript 7's
language service does not report a missing-declaration hint for the custom source
extension. Consumers need no `compilerOptions.paths` overrides or ambient module
declarations; they only enable the mapper and extension as shown above.
Dify-specific subpaths require the repository's Python and JavaScript development
dependencies, but no running API or database. They are workspace integrations and
are excluded from the standalone npm SDK's published exports and files. The
standalone package continues to support generic OpenAPI imports. Production
consumers of `@dify/contracts` are unaffected.
#### Type-only command sources
`.openapi-types` uses the same descriptor shape, watched-input validation and
cache invalidation as `.openapi-source`. Its exporter writes JSON of the form
`{"typescript":"export type Example = { id: string };"}` to stdout. The mapper
uses this TypeScript module as virtual compiler input. Exporter logs belong on
stderr, and empty or malformed results are diagnostics, not cached successes.
These files are for type resolution, not runtime imports. Dify subpaths expose
only a `types` target; runtime imports continue to use the original contracts. Ordinary OpenAPI imports still export `Schemas`,
`Inputs`, `Outputs`, `api` and `query` as described above.
Descriptors can share a configuration with `extends`. `extends` and `cwd` paths
are resolved from the descriptor that defines them; input patterns are relative
to the effective `cwd`. Other fields replace inherited values, and `args` is
appended to the effective command. Every inherited descriptor
is watched and included in cache invalidation. For example:
```json
{
"extends": "../community.source.json",
"args": ["api/console/datasets"]
}
```
### Document configuration
Place `pets.openapi.yaml.config.ts` next to `pets.openapi.yaml`:
@@ -438,6 +608,16 @@ No running API, database or private source checkout is needed. CI installs the
Python environment before the standalone Console checks. `compose:console` now
prints the composed OpenAPI JSON to stdout instead of creating a file.
Open [the real Dify type examples] to inspect dataset
models, list/create inputs and outputs, multipart file uploads, and compatibility
aliases retaining existing contracts names and request envelopes. The example
imports `Schemas`, `Inputs` and `Outputs` from the live Console source descriptor;
its sample values do not send requests. Check all repository examples with:
```sh
pnpm --filter @dify/openapi-content-mapper exec tsc --runExternalCode --noEmit -p examples/repository/tsconfig.json
```
The package uses `vp pack` and builds through `prepare` during workspace installation,
like `@langgenius/dev-proxy`. Web's `prebuild` and `prebuild:vinext` hooks also build
the SDK so production builds work after an installation with `--ignore-scripts`,
@@ -486,4 +666,8 @@ This package retains the repository's modified Apache 2.0 terms, including its a
[CHANGELOG]: CHANGELOG.md
[Development and release]: #development-and-release
[LICENSE]: LICENSE
[Petstore type examples]: examples/petstore/types.ts
[VS Code extension]: ./vscode/README.md
[repository compatibility example]: examples/repository/compatibility.ts
[the real Dify type examples]: examples/repository/types.ts
[the runnable demo]: examples/petstore/demo.ts
@@ -0,0 +1,21 @@
{
"command": ["node", "packages/contracts/scripts/export-compatibility-types.ts"],
"cwd": "../../..",
"inputs": [
"packages/contracts/api-documents.ts",
"packages/contracts/typescript-generation.ts",
"packages/contracts/scripts/export-compatibility-types.ts",
"packages/contracts/package.json",
"pnpm-lock.yaml",
"packages/contracts/generated/api/**/types.gen.ts",
"api/**/*.py",
"dify-agent/src/**/*.py",
"dify-agent/pyproject.toml",
"api/pyproject.toml",
"api/uv.lock"
],
"env": ["UV_PROJECT_ENVIRONMENT"],
"exclude": ["api/tests/**"],
"optionalInputs": [".env"],
"timeoutMs": 120000
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './account.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/account"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './activate.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/activate"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './agent.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/agent"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './all-workspaces.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/all-workspaces"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './api-based-extension.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/api-based-extension"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './api-key-auth.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/api-key-auth"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './app-dsl-version.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/app-dsl-version"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './app.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/app"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './apps.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/apps"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './auth.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/auth"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './billing.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/billing"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './code-based-extension.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/code-based-extension"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './compliance.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/compliance"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './data-source.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/data-source"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './datasets.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/datasets"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './email-code-login.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/email-code-login"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './email-register.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/email-register"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './explore.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/explore"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './features.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/features"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './files.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/files"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './forgot-password.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/forgot-password"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './form.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/form"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './init.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/init"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './installed-apps.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/installed-apps"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './instruction-generate.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/instruction-generate"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './knowledge-fs.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/knowledge-fs"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './login.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/login"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './logout.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/logout"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './notification.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/notification"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './notion.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/notion"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './oauth.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/oauth"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './onboarding.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/onboarding"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './ping.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/ping"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './rag.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/rag"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './refresh-token.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/refresh-token"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './remote-files.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/remote-files"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './reset-password.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/reset-password"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './resource-access-tokens.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/resource-access-tokens"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './rule-code-generate.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/rule-code-generate"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './rule-generate.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/rule-generate"
]
}
@@ -0,0 +1,2 @@
// Generated by @dify/contracts gen-compatibility-sources.
export type * from './rule-structured-output-generate.openapi-types'
@@ -0,0 +1,6 @@
{
"extends": "../community.source.json",
"args": [
"api/console/rule-structured-output-generate"
]
}
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more