mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
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:
1 parent
430d9aff99
commit
04ba877c1a
165 files changed
+3287
-624
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`)."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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\./, '')
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Generated by @dify/contracts gen-compatibility-sources.
|
||||
export type * from './rule-structured-output-generate.openapi-types'
|
||||
+6
@@ -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
Reference in new issue
Block a user