mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 06:41:07 +08:00
316 lines
14 KiB
Python
316 lines
14 KiB
Python
"""Web management of personal API-key and OAuth Connections."""
|
|
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from flask import request
|
|
from pydantic import ConfigDict, Field, JsonValue, RootModel, ValidationError
|
|
from pydantic.json_schema import SkipJsonSchema
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
from controllers.common.connection_fields import (
|
|
ConnectionConfigurationResponse,
|
|
EndUserConnectionListResponse,
|
|
EndUserConnectionResponse,
|
|
)
|
|
from controllers.common.end_user_connections import (
|
|
ConnectionCheckUnavailableHTTPError,
|
|
ConnectionConfigurationHTTPError,
|
|
ConnectionConflictHTTPError,
|
|
ConnectionCredentialsHTTPError,
|
|
ConnectionLoginRequiredError,
|
|
ConnectionNotFoundHTTPError,
|
|
ConnectionReauthorizationHTTPError,
|
|
ConnectionRevisionChangedHTTPError,
|
|
ConnectionSecretUnavailableHTTPError,
|
|
)
|
|
from controllers.common.end_user_connections import (
|
|
raise_connection_error as _raise_error,
|
|
)
|
|
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
|
from controllers.web import web_ns
|
|
from controllers.web.wraps import WebApiResource
|
|
from core.entities.provider_entities import ProviderConfig
|
|
from extensions.ext_application_services import application_services
|
|
from fields.base import ResponseModel
|
|
from libs.external_api import error_responses
|
|
from libs.helper import dump_response
|
|
from models.model import App, EndUser
|
|
from services.end_user_connection_identity import connection_context_from_web_passport
|
|
from services.end_user_connections.contracts import (
|
|
ConnectionInput,
|
|
ConnectionRevisionView,
|
|
ConnectionTargetInput,
|
|
CreateApiKeyConnectionInput,
|
|
ListConnectionsInput,
|
|
UpdateConnectionInput,
|
|
)
|
|
from services.end_user_connections.errors import ConnectionError
|
|
from services.end_user_connections.revision import ConnectionRevision
|
|
from services.end_user_connections.selections import SaveConnectionConfigurationInput
|
|
from services.errors.conversation import ConversationNotExistsError
|
|
|
|
|
|
class ConnectionConfigurationQuery(ConnectionInput):
|
|
conversation_id: UUID | None = Field(
|
|
default=None,
|
|
strict=False,
|
|
description=(
|
|
"Existing Chatflow Conversation whose choices to read, including an empty set. "
|
|
"Omit for App defaults when initializing a new conversation or plain Workflow."
|
|
),
|
|
)
|
|
|
|
|
|
class ConnectionConfigurationPayload(SaveConnectionConfigurationInput):
|
|
pass
|
|
|
|
|
|
register_schema_models(web_ns, ConnectionConfigurationPayload)
|
|
register_response_schema_models(web_ns, ConnectionConfigurationResponse)
|
|
|
|
|
|
@web_ns.route("/connections/configuration")
|
|
class ConnectionConfigurationApi(WebApiResource):
|
|
@web_ns.doc(params=query_params_from_model(ConnectionConfigurationQuery))
|
|
@web_ns.doc(
|
|
description="Read saved choices and whether authorization must be renewed, without checking credentials."
|
|
)
|
|
@web_ns.response(200, "Saved Connection configuration", web_ns.models[ConnectionConfigurationResponse.__name__])
|
|
@error_responses(ConnectionConfigurationHTTPError, ConnectionCheckUnavailableHTTPError)
|
|
def get(self, app_model: App, end_user: EndUser):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
query = ConnectionConfigurationQuery.model_validate(request.args.to_dict(flat=True))
|
|
result = application_services().connection_selections.get(
|
|
context, str(query.conversation_id) if query.conversation_id is not None else None
|
|
)
|
|
except ValidationError:
|
|
raise ValueError("Invalid connection configuration query.") from None
|
|
except ConversationNotExistsError:
|
|
raise NotFound("Conversation Not Exists.") from None
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(ConnectionConfigurationResponse, result)
|
|
|
|
@web_ns.expect(web_ns.models[ConnectionConfigurationPayload.__name__])
|
|
@web_ns.doc(description="Replace saved choices and explicitly approve their authorization contract atomically.")
|
|
@web_ns.response(200, "Connection configuration saved", web_ns.models[ConnectionConfigurationResponse.__name__])
|
|
@error_responses(
|
|
ConnectionLoginRequiredError,
|
|
ConnectionConfigurationHTTPError,
|
|
ConnectionNotFoundHTTPError,
|
|
ConnectionConflictHTTPError,
|
|
ConnectionRevisionChangedHTTPError,
|
|
ConnectionReauthorizationHTTPError,
|
|
ConnectionCheckUnavailableHTTPError,
|
|
)
|
|
def put(self, app_model: App, end_user: EndUser):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
payload = ConnectionConfigurationPayload.model_validate(web_ns.payload or {})
|
|
result = application_services().connection_selections.save(context, payload)
|
|
except ValidationError:
|
|
raise ValueError("Invalid connection configuration payload.") from None
|
|
except ConversationNotExistsError:
|
|
raise NotFound("Conversation Not Exists.") from None
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(ConnectionConfigurationResponse, result)
|
|
|
|
|
|
class EndUserConnectionCreatePayload(CreateApiKeyConnectionInput):
|
|
pass
|
|
|
|
|
|
def _omit_schema_default(schema: dict[str, Any]) -> None:
|
|
schema.pop("default", None)
|
|
|
|
|
|
class EndUserConnectionUpdatePayload(UpdateConnectionInput):
|
|
model_config = ConfigDict(
|
|
json_schema_extra={"minProperties": 1, "dependentRequired": {"credentials": ["connection_revision"]}}
|
|
)
|
|
|
|
connection_revision: ConnectionRevision | SkipJsonSchema[None] = Field(
|
|
default=None,
|
|
json_schema_extra=_omit_schema_default,
|
|
description="Provider revision required when replacing credentials.",
|
|
)
|
|
|
|
name: str | SkipJsonSchema[None] = Field(
|
|
default=None, min_length=1, max_length=100, json_schema_extra=_omit_schema_default
|
|
)
|
|
credentials: dict[str, JsonValue] | SkipJsonSchema[None] = Field(
|
|
default=None, min_length=1, repr=False, json_schema_extra=_omit_schema_default
|
|
)
|
|
|
|
|
|
class EndUserConnectionRevisionResponse(ConnectionRevisionView, ResponseModel):
|
|
pass
|
|
|
|
|
|
class ConnectionCredentialSchemaQuery(ConnectionTargetInput):
|
|
pass
|
|
|
|
|
|
class ConnectionCredentialSchemaResponse(RootModel[list[ProviderConfig]]):
|
|
pass
|
|
|
|
|
|
class EndUserConnectionListQuery(ListConnectionsInput):
|
|
limit: int = Field(default=20, ge=1, le=100, strict=False)
|
|
|
|
|
|
register_schema_models(
|
|
web_ns,
|
|
EndUserConnectionCreatePayload,
|
|
EndUserConnectionUpdatePayload,
|
|
EndUserConnectionListQuery,
|
|
)
|
|
register_response_schema_models(
|
|
web_ns,
|
|
EndUserConnectionResponse,
|
|
EndUserConnectionListResponse,
|
|
EndUserConnectionRevisionResponse,
|
|
ConnectionCredentialSchemaResponse,
|
|
)
|
|
|
|
|
|
@web_ns.route("/connections/credential-schema")
|
|
class ConnectionCredentialSchemaApi(WebApiResource):
|
|
@web_ns.doc(params=query_params_from_model(ConnectionCredentialSchemaQuery))
|
|
@web_ns.doc(description="Read API-key form fields for an authorized published tool node; no credential values.")
|
|
@web_ns.response(200, "API-key form schema", web_ns.models[ConnectionCredentialSchemaResponse.__name__])
|
|
@error_responses(
|
|
ConnectionLoginRequiredError,
|
|
ConnectionConfigurationHTTPError,
|
|
ConnectionRevisionChangedHTTPError,
|
|
ConnectionCheckUnavailableHTTPError,
|
|
)
|
|
def get(self, app_model: App, end_user: EndUser):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
query = ConnectionCredentialSchemaQuery.model_validate(request.args.to_dict(flat=True))
|
|
result = application_services().end_user_connections.get_api_key_schema(context, query)
|
|
except ValidationError:
|
|
raise ValueError("Invalid connection schema query.") from None
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(ConnectionCredentialSchemaResponse, result), 200, {"Cache-Control": "no-store"}
|
|
|
|
|
|
@web_ns.route("/connections")
|
|
class EndUserConnectionListApi(WebApiResource):
|
|
@web_ns.doc(params=query_params_from_model(EndUserConnectionListQuery))
|
|
@web_ns.response(200, "Personal connections", web_ns.models[EndUserConnectionListResponse.__name__])
|
|
@error_responses(ConnectionLoginRequiredError, ConnectionConfigurationHTTPError)
|
|
def get(self, app_model: App, end_user: EndUser):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
query = EndUserConnectionListQuery.model_validate(request.args.to_dict(flat=True))
|
|
result = application_services().end_user_connections.list_connections(context, query)
|
|
except ValidationError:
|
|
raise ValueError("Invalid connection query.") from None
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(EndUserConnectionListResponse, result)
|
|
|
|
@web_ns.expect(web_ns.models[EndUserConnectionCreatePayload.__name__])
|
|
@web_ns.response(201, "API-key connection created", web_ns.models[EndUserConnectionResponse.__name__])
|
|
@error_responses(
|
|
ConnectionLoginRequiredError,
|
|
ConnectionConfigurationHTTPError,
|
|
ConnectionRevisionChangedHTTPError,
|
|
ConnectionCredentialsHTTPError,
|
|
ConnectionCheckUnavailableHTTPError,
|
|
ConnectionSecretUnavailableHTTPError,
|
|
)
|
|
def post(self, app_model: App, end_user: EndUser):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
payload = EndUserConnectionCreatePayload.model_validate(web_ns.payload or {})
|
|
result = application_services().end_user_connections.create_api_key(context, payload)
|
|
except ValidationError:
|
|
raise ValueError("Invalid connection payload.") from None
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(EndUserConnectionResponse, result), 201
|
|
|
|
|
|
@web_ns.route("/connections/<uuid:connection_id>")
|
|
class EndUserConnectionApi(WebApiResource):
|
|
@web_ns.response(200, "Personal connection", web_ns.models[EndUserConnectionResponse.__name__])
|
|
@error_responses(ConnectionLoginRequiredError, ConnectionConfigurationHTTPError, ConnectionNotFoundHTTPError)
|
|
def get(self, app_model: App, end_user: EndUser, connection_id: UUID):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
result = application_services().end_user_connections.get_connection(context, str(connection_id))
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(EndUserConnectionResponse, result)
|
|
|
|
@web_ns.expect(web_ns.models[EndUserConnectionUpdatePayload.__name__])
|
|
@web_ns.response(200, "Connection updated", web_ns.models[EndUserConnectionResponse.__name__])
|
|
@error_responses(
|
|
ConnectionLoginRequiredError,
|
|
ConnectionConfigurationHTTPError,
|
|
ConnectionNotFoundHTTPError,
|
|
ConnectionConflictHTTPError,
|
|
ConnectionRevisionChangedHTTPError,
|
|
ConnectionCredentialsHTTPError,
|
|
ConnectionCheckUnavailableHTTPError,
|
|
ConnectionSecretUnavailableHTTPError,
|
|
)
|
|
def patch(self, app_model: App, end_user: EndUser, connection_id: UUID):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
payload = EndUserConnectionUpdatePayload.model_validate(web_ns.payload or {})
|
|
result = application_services().end_user_connections.update_connection(context, str(connection_id), payload)
|
|
except ValidationError:
|
|
raise ValueError("Invalid connection payload.") from None
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(EndUserConnectionResponse, result)
|
|
|
|
@web_ns.doc(
|
|
description=(
|
|
"Disconnect locally and remove live selections, then attempt OAuth revocation against the original "
|
|
"plugin artifact and Client. Remote failures never undo local deletion or fall back to installed versions."
|
|
)
|
|
)
|
|
@web_ns.response(204, "Connection deleted")
|
|
@error_responses(
|
|
ConnectionLoginRequiredError,
|
|
ConnectionConfigurationHTTPError,
|
|
ConnectionNotFoundHTTPError,
|
|
ConnectionConflictHTTPError,
|
|
)
|
|
def delete(self, app_model: App, end_user: EndUser, connection_id: UUID):
|
|
try:
|
|
context = connection_context_from_web_passport(app=app_model, end_user=end_user)
|
|
application_services().end_user_connections.delete_connection(context, str(connection_id))
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return "", 204
|
|
|
|
|
|
@web_ns.route("/connections/<uuid:connection_id>/revision")
|
|
class EndUserConnectionRevisionApi(WebApiResource):
|
|
@web_ns.response(200, "Current Provider revision", web_ns.models[EndUserConnectionRevisionResponse.__name__])
|
|
@web_ns.doc(description="Read a Provider revision for an owned Connection, independently of published tool nodes.")
|
|
@error_responses(
|
|
ConnectionLoginRequiredError,
|
|
ConnectionNotFoundHTTPError,
|
|
ConnectionConfigurationHTTPError,
|
|
ConnectionCheckUnavailableHTTPError,
|
|
)
|
|
def get(self, app_model: App, end_user: EndUser, connection_id: UUID):
|
|
try:
|
|
result = application_services().end_user_connections.get_connection_revision(
|
|
connection_context_from_web_passport(app=app_model, end_user=end_user), str(connection_id)
|
|
)
|
|
except ConnectionError as error:
|
|
_raise_error(error)
|
|
return dump_response(EndUserConnectionRevisionResponse, result), 200, {"Cache-Control": "no-store"}
|