From 328461cefe35151fddfb774bd504d4a3ffe433e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Fri, 17 Oct 2025 21:02:00 +0300 Subject: [PATCH 01/13] feat: Added OperationReply models --- .../asyncapi/v3_0_0/schema/operation_reply.py | 21 +++++++++++++++++++ .../asyncapi/v3_0_0/schema/operations.py | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py new file mode 100644 index 00000000000..6371d795c5c --- /dev/null +++ b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field +from .utils import Reference + + +from faststream._internal._compat import PYDANTIC_V2 + + +class OperationReplyAddress(BaseModel): + description: str | None = None + location: str + +class OperationReply(BaseModel): + address: OperationReplyAddress + channel: Reference + messages: list[Reference] = Field(default_factory=list) + + if PYDANTIC_V2: + model_config = {"extra": "allow"} + else: + class Config: + extra = "allow" \ No newline at end of file diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operations.py b/faststream/specification/asyncapi/v3_0_0/schema/operations.py index 05ca29fbf01..8e17f25d66d 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/operations.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/operations.py @@ -9,6 +9,7 @@ from .bindings import OperationBinding from .channels import Channel +from .operation_reply import OperationReply from .tag import Tag from .utils import Reference @@ -43,6 +44,8 @@ class Operation(BaseModel): security: dict[str, list[str]] | None = None + reply: OperationReply | None = None + # TODO # traits From 839efb4436fec9cb9205a70dc0048477f43238fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Mon, 20 Oct 2025 01:16:33 +0300 Subject: [PATCH 02/13] feat: Added base for generate reply --- .../endpoint/subscriber/specification.py | 29 +++++++- faststream/rabbit/subscriber/specification.py | 5 ++ faststream/specification/asyncapi/message.py | 21 ++++++ .../specification/asyncapi/v3_0_0/generate.py | 71 +++++++++++++++++++ .../asyncapi/v3_0_0/schema/__init__.py | 2 + .../asyncapi/v3_0_0/schema/operation_reply.py | 21 ++++-- .../asyncapi/v3_0_0/schema/operations.py | 2 + .../specification/schema/operation/model.py | 1 + 8 files changed, 145 insertions(+), 7 deletions(-) diff --git a/faststream/_internal/endpoint/subscriber/specification.py b/faststream/_internal/endpoint/subscriber/specification.py index 369649713c5..20de4cd1fbd 100644 --- a/faststream/_internal/endpoint/subscriber/specification.py +++ b/faststream/_internal/endpoint/subscriber/specification.py @@ -7,7 +7,7 @@ from faststream._internal.configs import BrokerConfig, SubscriberSpecificationConfig from faststream.exceptions import SetupError -from faststream.specification.asyncapi.message import parse_handler_params +from faststream.specification.asyncapi.message import parse_handler_params, parse_handler_return from faststream.specification.asyncapi.utils import to_camelcase if TYPE_CHECKING: @@ -78,6 +78,33 @@ def get_payloads(self) -> list[tuple["dict[str, Any]", str]]: return payloads + def get_reply_payloads(self) -> list[tuple["dict[str, Any]", str]]: + payloads: list[tuple[dict[str, Any], str]] = [] + + call_name = self.call_name + + for h in self.calls: + if h.dependant is None: + msg = "You should setup `Handler` at first." + raise SetupError(msg) + + reply_body = parse_handler_return( + h.dependant, + prefix=f"{self.config.title_ or call_name}:ReplyMessage", + ) + payloads.append((reply_body, to_camelcase(h.name))) + + if not self.calls: + payloads.append( + ( + { + "title": f"{self.config.title_ or call_name}:ReplyMessage:Payload", + }, + to_camelcase(call_name), + ), + ) + + return payloads @property @abstractmethod def name(self) -> str: diff --git a/faststream/rabbit/subscriber/specification.py b/faststream/rabbit/subscriber/specification.py index 78408c8b5dd..c3b2b22d3a6 100644 --- a/faststream/rabbit/subscriber/specification.py +++ b/faststream/rabbit/subscriber/specification.py @@ -31,6 +31,7 @@ def name(self) -> str: def get_schema(self) -> dict[str, SubscriberSpec]: payloads = self.get_payloads() + reply_payloads = self.get_reply_payloads() queue = self.config.queue.add_prefix(self._outer_config.prefix) @@ -59,6 +60,10 @@ def get_schema(self) -> dict[str, SubscriberSpec]: title=f"{channel_name}:Message", payload=resolve_payloads(payloads), ), + reply_message=Message( + title=f"{channel_name}:ReplyMessage", + payload=resolve_payloads(reply_payloads), + ) ), bindings=ChannelBinding( amqp=amqp.ChannelBinding( diff --git a/faststream/specification/asyncapi/message.py b/faststream/specification/asyncapi/message.py index 16946a69da9..5dd39198bbb 100644 --- a/faststream/specification/asyncapi/message.py +++ b/faststream/specification/asyncapi/message.py @@ -35,6 +35,27 @@ def parse_handler_params(call: "CallModel", prefix: str = "") -> dict[str, Any]: return body +def parse_handler_return(call: "CallModel", prefix: str = "") -> dict[str, Any]: + """Parses the handler parameters.""" + model_container = getattr(call, "serializer") + model = cast("type[BaseModel] | None", getattr(model_container, "model", None)) + assert model + out = model_container.response_option['return'] + + body = get_model_schema( + create_model( + model.__name__, + **{out.field_name: (out.field_type, out.default_value)}, # type: ignore[call-overload] + ), + prefix=prefix, + exclude=tuple(call.custom_fields.keys()), + ) + + if body is None: + return {"title": "EmptyPayload", "type": "null"} + + return body + @overload def get_response_schema(call: None, prefix: str = "") -> None: ... diff --git a/faststream/specification/asyncapi/v3_0_0/generate.py b/faststream/specification/asyncapi/v3_0_0/generate.py index b08b86f823f..d0da565dfbe 100644 --- a/faststream/specification/asyncapi/v3_0_0/generate.py +++ b/faststream/specification/asyncapi/v3_0_0/generate.py @@ -19,6 +19,7 @@ Operation, Reference, Server, + OperationReply, Tag, ) from faststream.specification.asyncapi.v3_0_0.schema.bindings import ( @@ -64,6 +65,7 @@ def get_app_schema( channels, operations = get_broker_channels(broker) messages: dict[str, Message] = {} + reply_messages: dict[str, Message] = {} payloads: dict[str, dict[str, Any]] = {} for channel in channels.values(): @@ -89,6 +91,17 @@ def get_app_schema( channel.messages = msgs + for operation_name, operation in operations.items(): + reply_msgs: dict[str, Message | Reference] = {} + for message in operation.reply.messages: + reply_msgs['ReplyMessage'] = _resolve_reply_payloads( + 'ReplyMessage', + message, + payloads, + reply_messages, + ) + + messages.update(reply_messages) return ApplicationSchema( info=ApplicationInfo( title=title, @@ -190,6 +203,9 @@ def get_broker_channels( ], channel=Reference(**{"$ref": f"#/channels/{channel_key}"}), operation=sub_channel.operation, + reply=OperationReply( + messages=[Message.from_spec(sub_channel.operation.reply_message)], + ) ) for pub in filter(lambda p: p.specification.include_in_schema, broker.publishers): @@ -256,6 +272,61 @@ def get_asgi_routes( def _get_http_binding_method(methods: Sequence[str]) -> str: return next((method for method in methods if method != "HEAD"), "HEAD") +def _resolve_reply_payloads( + message_name: str, + m: Message, + payloads: dict[str, Any], + reply_messages: dict[str, Any], +) -> Reference: + assert isinstance(m.payload, dict) + + m.payload = move_pydantic_refs(m.payload, DEF_KEY) + + message_name = clear_key(message_name) + + if DEF_KEY in m.payload: + payloads.update(m.payload.pop(DEF_KEY)) + + one_of = m.payload.get("oneOf", None) + if isinstance(one_of, dict): + one_of_list = [] + processed_payloads: dict[str, dict[str, Any]] = {} + for name, payload in one_of.items(): + # Promote nested Pydantic $defs from each payload into components/schemas + # so that referenced nested models are available globally. + if isinstance(payload, dict) and DEF_KEY in payload: + defs = payload.pop(DEF_KEY) or {} + for def_name, def_schema in defs.items(): + payloads[clear_key(def_name)] = def_schema + processed_payloads[clear_key(name)] = payload + one_of_list.append(Reference(**{"$ref": f"#/components/schemas/{name}"})) + + payloads.update(processed_payloads) + m.payload["oneOf"] = one_of_list + assert m.title + reply_messages[clear_key(m.title)] = m + return Reference( + **{"$ref": f"#/components/messages/{message_name}"}, + ) + + payloads.update(m.payload.pop(DEF_KEY, {})) + payload_name = m.payload.get("title", f"{message_name}:Payload") + payload_name = clear_key(payload_name) + + if payload_name in payloads and payloads[payload_name] != m.payload: + warnings.warn( + f"Overwriting the message schema, data types have the same name: `{payload_name}`", + RuntimeWarning, + stacklevel=1, + ) + + payloads[payload_name] = m.payload + m.payload = {"$ref": f"#/components/schemas/{payload_name}"} + assert m.title + reply_messages[clear_key(m.title)] = m + return Reference( + **{"$ref": f"#/components/messages/{message_name}"}, + ) def _resolve_msg_payloads( message_name: str, diff --git a/faststream/specification/asyncapi/v3_0_0/schema/__init__.py b/faststream/specification/asyncapi/v3_0_0/schema/__init__.py index e0cbcbd7b25..42b5c3a3c19 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/__init__.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/__init__.py @@ -6,6 +6,7 @@ from .license import License from .message import CorrelationId, Message from .operations import Operation +from .operation_reply import OperationReply from .schema import ApplicationSchema from .servers import Server, ServerVariable from .tag import Tag @@ -23,6 +24,7 @@ "License", "Message", "Operation", + "OperationReply", "Parameter", "Reference", "Server", diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py index 6371d795c5c..5fac70648c5 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py @@ -1,8 +1,11 @@ from pydantic import BaseModel, Field -from .utils import Reference - +from typing_extensions import Self from faststream._internal._compat import PYDANTIC_V2 +from faststream.specification.asyncapi.v3_0_0.schema.message import Message +from faststream.specification.schema import Operation + +from .utils import Reference class OperationReplyAddress(BaseModel): @@ -10,12 +13,18 @@ class OperationReplyAddress(BaseModel): location: str class OperationReply(BaseModel): - address: OperationReplyAddress - channel: Reference - messages: list[Reference] = Field(default_factory=list) + messages: list[Message | Reference] + channel: Reference | None = None + address: OperationReplyAddress | None = None if PYDANTIC_V2: model_config = {"extra": "allow"} else: class Config: - extra = "allow" \ No newline at end of file + extra = "allow" + + @classmethod + def from_sub(cls, messages: list[Reference] ) -> Self: + return cls( + messages=messages + ) \ No newline at end of file diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operations.py b/faststream/specification/asyncapi/v3_0_0/schema/operations.py index 8e17f25d66d..238a34c510b 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/operations.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/operations.py @@ -65,12 +65,14 @@ def from_sub( messages: list[Reference], channel: Reference, operation: OperationSpec, + reply: OperationReply, ) -> Self: return cls( action=Action.RECEIVE, messages=messages, channel=channel, bindings=OperationBinding.from_sub(operation.bindings), + reply=reply, summary=None, description=None, security=None, diff --git a/faststream/specification/schema/operation/model.py b/faststream/specification/schema/operation/model.py index 58f426dc17e..3e8d44d37c6 100644 --- a/faststream/specification/schema/operation/model.py +++ b/faststream/specification/schema/operation/model.py @@ -8,3 +8,4 @@ class Operation: message: Message bindings: OperationBinding | None + reply_message: Message | None From 997933afb2dd7095d6c3ab402e51d5c7faee353e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sat, 1 Nov 2025 15:13:51 +0300 Subject: [PATCH 03/13] feat: Added channel info to reply --- .../endpoint/subscriber/specification.py | 1 + faststream/rabbit/publisher/specification.py | 1 + faststream/specification/asyncapi/message.py | 11 +++---- .../specification/asyncapi/v3_0_0/generate.py | 29 +++++++++++++++---- .../asyncapi/v3_0_0/schema/operation_reply.py | 10 +------ .../asyncapi/v3_0_0/schema/operations.py | 3 +- .../specification/schema/operation/model.py | 2 +- .../specification/schema/reply/__init__.py | 3 ++ .../specification/schema/reply/model.py | 14 +++++++++ 9 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 faststream/specification/schema/reply/__init__.py create mode 100644 faststream/specification/schema/reply/model.py diff --git a/faststream/_internal/endpoint/subscriber/specification.py b/faststream/_internal/endpoint/subscriber/specification.py index 20de4cd1fbd..2530dfc6eb8 100644 --- a/faststream/_internal/endpoint/subscriber/specification.py +++ b/faststream/_internal/endpoint/subscriber/specification.py @@ -105,6 +105,7 @@ def get_reply_payloads(self) -> list[tuple["dict[str, Any]", str]]: ) return payloads + @property @abstractmethod def name(self) -> str: diff --git a/faststream/rabbit/publisher/specification.py b/faststream/rabbit/publisher/specification.py index 834ba356b5a..34fcd916cc9 100644 --- a/faststream/rabbit/publisher/specification.py +++ b/faststream/rabbit/publisher/specification.py @@ -70,6 +70,7 @@ def get_schema(self) -> dict[str, "PublisherSpec"]: served_words=2 if self.config.title_ is None else 1, ), ), + reply_message=None ), bindings=ChannelBinding( amqp=amqp.ChannelBinding( diff --git a/faststream/specification/asyncapi/message.py b/faststream/specification/asyncapi/message.py index 5dd39198bbb..6d208ea2c84 100644 --- a/faststream/specification/asyncapi/message.py +++ b/faststream/specification/asyncapi/message.py @@ -37,14 +37,15 @@ def parse_handler_params(call: "CallModel", prefix: str = "") -> dict[str, Any]: def parse_handler_return(call: "CallModel", prefix: str = "") -> dict[str, Any]: """Parses the handler parameters.""" - model_container = getattr(call, "serializer") - model = cast("type[BaseModel] | None", getattr(model_container, "model", None)) - assert model - out = model_container.response_option['return'] + model_container = getattr(call, "serializer", call) + response_option = getattr(model_container, "response_option", None) + if not response_option: + return {"title": "EmptyPayload", "type": "null"} + out = response_option["return"] body = get_model_schema( create_model( - model.__name__, + "", **{out.field_name: (out.field_type, out.default_value)}, # type: ignore[call-overload] ), prefix=prefix, diff --git a/faststream/specification/asyncapi/v3_0_0/generate.py b/faststream/specification/asyncapi/v3_0_0/generate.py index d0da565dfbe..442cda404ae 100644 --- a/faststream/specification/asyncapi/v3_0_0/generate.py +++ b/faststream/specification/asyncapi/v3_0_0/generate.py @@ -1,7 +1,7 @@ import string import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import Callable, TYPE_CHECKING, Any, Optional, Union from urllib.parse import urlparse from faststream._internal._compat import DEF_KEY @@ -26,6 +26,7 @@ OperationBinding, http as http_bindings, ) +from faststream.specification.asyncapi.v3_0_0.schema.operation_reply import OperationReplyAddress from faststream.specification.asyncapi.v3_0_0.schema.operations import Action if TYPE_CHECKING: @@ -93,15 +94,19 @@ def get_app_schema( for operation_name, operation in operations.items(): reply_msgs: dict[str, Message | Reference] = {} + if not operation.reply: + continue for message in operation.reply.messages: reply_msgs['ReplyMessage'] = _resolve_reply_payloads( - 'ReplyMessage', + f'{operation_name.removesuffix("Subscribe")}:ReplyMessage', message, payloads, reply_messages, ) + operation.reply.messages = list(reply_msgs.values()) messages.update(reply_messages) + return ApplicationSchema( info=ApplicationInfo( title=title, @@ -179,6 +184,7 @@ def get_broker_channels( """Get the broker channels for an application.""" channels = {} operations = {} + operations_by_handler: dict[Callable, Operation] = {} for sub in filter(lambda s: s.specification.include_in_schema, broker.subscribers): for sub_key, sub_channel in sub.schema().items(): @@ -194,7 +200,7 @@ def get_broker_channels( channels[channel_key] = channel_obj - operations[f"{channel_key}Subscribe"] = Operation.from_sub( + operation = Operation.from_sub( messages=[ Reference(**{ "$ref": f"#/channels/{channel_key}/messages/{msg_name}", @@ -204,9 +210,17 @@ def get_broker_channels( channel=Reference(**{"$ref": f"#/channels/{channel_key}"}), operation=sub_channel.operation, reply=OperationReply( - messages=[Message.from_spec(sub_channel.operation.reply_message)], - ) + messages=[Message.from_spec(sub_channel.operation.reply_message)] if sub_channel.operation.reply_message else [], + address=OperationReplyAddress( + description=None, + location="$message.header#/replyTo", + ), + channel=None, + ) if not sub._no_reply else None, ) + operations[f"{channel_key}Subscribe"] = operation + for call in sub.specification.calls: + operations_by_handler[call.handler._original_call] = operation for pub in filter(lambda p: p.specification.include_in_schema, broker.publishers): for pub_key, pub_channel in pub.schema().items(): @@ -231,6 +245,11 @@ def get_broker_channels( channel=Reference(**{"$ref": f"#/channels/{channel_key}"}), operation=pub_channel.operation, ) + for call in pub.specification.calls: + sub_operation = operations_by_handler.get(call) + if sub_operation is None or sub_operation.reply is None: + continue + sub_operation.reply.channel = Reference(**{"$ref": f"#/channels/{channel_key}"}) return channels, operations diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py index 5fac70648c5..323038b73de 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py @@ -1,9 +1,7 @@ -from pydantic import BaseModel, Field -from typing_extensions import Self +from pydantic import BaseModel from faststream._internal._compat import PYDANTIC_V2 from faststream.specification.asyncapi.v3_0_0.schema.message import Message -from faststream.specification.schema import Operation from .utils import Reference @@ -22,9 +20,3 @@ class OperationReply(BaseModel): else: class Config: extra = "allow" - - @classmethod - def from_sub(cls, messages: list[Reference] ) -> Self: - return cls( - messages=messages - ) \ No newline at end of file diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operations.py b/faststream/specification/asyncapi/v3_0_0/schema/operations.py index 238a34c510b..3bcf57e60cc 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/operations.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/operations.py @@ -65,7 +65,7 @@ def from_sub( messages: list[Reference], channel: Reference, operation: OperationSpec, - reply: OperationReply, + reply: OperationReply | None, ) -> Self: return cls( action=Action.RECEIVE, @@ -91,6 +91,7 @@ def from_pub( messages=messages, channel=channel, bindings=OperationBinding.from_pub(operation.bindings), + reply=None, summary=None, description=None, security=None, diff --git a/faststream/specification/schema/operation/model.py b/faststream/specification/schema/operation/model.py index 3e8d44d37c6..b2780c3cdf3 100644 --- a/faststream/specification/schema/operation/model.py +++ b/faststream/specification/schema/operation/model.py @@ -8,4 +8,4 @@ class Operation: message: Message bindings: OperationBinding | None - reply_message: Message | None + reply_message: Message | None = None diff --git a/faststream/specification/schema/reply/__init__.py b/faststream/specification/schema/reply/__init__.py new file mode 100644 index 00000000000..09ef2e907f2 --- /dev/null +++ b/faststream/specification/schema/reply/__init__.py @@ -0,0 +1,3 @@ +from .model import OperationReply, OperationReplyAddress + +__all__ = ("OperationReply", "OperationReplyAddress") diff --git a/faststream/specification/schema/reply/model.py b/faststream/specification/schema/reply/model.py new file mode 100644 index 00000000000..449518fa9d1 --- /dev/null +++ b/faststream/specification/schema/reply/model.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from faststream.specification.schema import Message + + +@dataclass +class OperationReplyAddress: + location: str + description: str | None = None + +@dataclass +class OperationReply: + message: Message | None + address: OperationReplyAddress | None From b6a7f7b54958b5ed88089f7c6c153cfe88fc43c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sat, 1 Nov 2025 16:07:20 +0300 Subject: [PATCH 04/13] feat: Added reply to kafka subscriber --- faststream/confluent/subscriber/specification.py | 5 +++++ faststream/kafka/subscriber/specification.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/faststream/confluent/subscriber/specification.py b/faststream/confluent/subscriber/specification.py index 95680561a18..86386038e27 100644 --- a/faststream/confluent/subscriber/specification.py +++ b/faststream/confluent/subscriber/specification.py @@ -31,6 +31,7 @@ def name(self) -> str: def get_schema(self) -> dict[str, SubscriberSpec]: payloads = self.get_payloads() + reply_payloads = self.get_reply_payloads() channels = {} for t in self.topics: @@ -43,6 +44,10 @@ def get_schema(self) -> dict[str, SubscriberSpec]: title=f"{handler_name}:Message", payload=resolve_payloads(payloads), ), + reply_message=Message( + title=f"{handler_name}:ReplyMessage", + payload=resolve_payloads(reply_payloads), + ), bindings=None, ), bindings=ChannelBinding( diff --git a/faststream/kafka/subscriber/specification.py b/faststream/kafka/subscriber/specification.py index 5325069e9e8..367f509821e 100644 --- a/faststream/kafka/subscriber/specification.py +++ b/faststream/kafka/subscriber/specification.py @@ -31,6 +31,7 @@ def name(self) -> str: def get_schema(self) -> dict[str, SubscriberSpec]: payloads = self.get_payloads() + reply_payloads = self.get_reply_payloads() channels = {} for t in self.topics: @@ -43,6 +44,10 @@ def get_schema(self) -> dict[str, SubscriberSpec]: title=f"{handler_name}:Message", payload=resolve_payloads(payloads), ), + reply_message=Message( + title=f"{handler_name}:ReplyMessage", + payload=resolve_payloads(reply_payloads), + ), bindings=None, ), bindings=ChannelBinding( From 1cd974b190699123f1cbc349e203a051d56c8f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sat, 1 Nov 2025 16:14:23 +0300 Subject: [PATCH 05/13] feat: Added reply to nats subscriber --- faststream/nats/subscriber/specification.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/faststream/nats/subscriber/specification.py b/faststream/nats/subscriber/specification.py index 31f3f8b4459..25152aa13b8 100644 --- a/faststream/nats/subscriber/specification.py +++ b/faststream/nats/subscriber/specification.py @@ -23,6 +23,7 @@ def name(self) -> str: def get_schema(self) -> dict[str, SubscriberSpec]: payloads = self.get_payloads() + reply_payloads = self.get_reply_payloads() return { self.name: SubscriberSpec( @@ -32,6 +33,10 @@ def get_schema(self) -> dict[str, SubscriberSpec]: title=f"{self.name}:Message", payload=resolve_payloads(payloads), ), + reply_message=Message( + title=f"{self.name}:ReplyMessage", + payload=resolve_payloads(reply_payloads), + ), bindings=None, ), bindings=ChannelBinding( From b0b761b1e526b6e3881f81d43a738d9421d2fc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sat, 1 Nov 2025 16:14:42 +0300 Subject: [PATCH 06/13] feat: Added reply to redis subscriber --- faststream/redis/subscriber/specification.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/faststream/redis/subscriber/specification.py b/faststream/redis/subscriber/specification.py index 1ea1721b1d6..83c49f39236 100644 --- a/faststream/redis/subscriber/specification.py +++ b/faststream/redis/subscriber/specification.py @@ -20,6 +20,7 @@ class RedisSubscriberSpecification( ): def get_schema(self) -> dict[str, SubscriberSpec]: payloads = self.get_payloads() + reply_payloads = self.get_reply_payloads() return { self.name: SubscriberSpec( @@ -29,6 +30,10 @@ def get_schema(self) -> dict[str, SubscriberSpec]: title=f"{self.name}:Message", payload=resolve_payloads(payloads), ), + reply_message=Message( + title=f"{self.name}:ReplyMessage", + payload=resolve_payloads(reply_payloads), + ), bindings=None, ), bindings=ChannelBinding( From 907af9ae0a8e6f2067da60f40aa2b4dc35f5643c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sat, 1 Nov 2025 16:42:17 +0300 Subject: [PATCH 07/13] chore: lint --- .../endpoint/subscriber/specification.py | 5 ++- faststream/rabbit/publisher/specification.py | 1 - faststream/rabbit/subscriber/specification.py | 2 +- faststream/specification/asyncapi/message.py | 1 + .../specification/asyncapi/v3_0_0/generate.py | 40 ++++++++++++------- .../asyncapi/v3_0_0/schema/__init__.py | 2 +- .../asyncapi/v3_0_0/schema/operation_reply.py | 2 + .../specification/schema/reply/model.py | 1 + 8 files changed, 36 insertions(+), 18 deletions(-) diff --git a/faststream/_internal/endpoint/subscriber/specification.py b/faststream/_internal/endpoint/subscriber/specification.py index 2530dfc6eb8..985cfacf0cd 100644 --- a/faststream/_internal/endpoint/subscriber/specification.py +++ b/faststream/_internal/endpoint/subscriber/specification.py @@ -7,7 +7,10 @@ from faststream._internal.configs import BrokerConfig, SubscriberSpecificationConfig from faststream.exceptions import SetupError -from faststream.specification.asyncapi.message import parse_handler_params, parse_handler_return +from faststream.specification.asyncapi.message import ( + parse_handler_params, + parse_handler_return, +) from faststream.specification.asyncapi.utils import to_camelcase if TYPE_CHECKING: diff --git a/faststream/rabbit/publisher/specification.py b/faststream/rabbit/publisher/specification.py index 34fcd916cc9..834ba356b5a 100644 --- a/faststream/rabbit/publisher/specification.py +++ b/faststream/rabbit/publisher/specification.py @@ -70,7 +70,6 @@ def get_schema(self) -> dict[str, "PublisherSpec"]: served_words=2 if self.config.title_ is None else 1, ), ), - reply_message=None ), bindings=ChannelBinding( amqp=amqp.ChannelBinding( diff --git a/faststream/rabbit/subscriber/specification.py b/faststream/rabbit/subscriber/specification.py index c3b2b22d3a6..737634cb60c 100644 --- a/faststream/rabbit/subscriber/specification.py +++ b/faststream/rabbit/subscriber/specification.py @@ -63,7 +63,7 @@ def get_schema(self) -> dict[str, SubscriberSpec]: reply_message=Message( title=f"{channel_name}:ReplyMessage", payload=resolve_payloads(reply_payloads), - ) + ), ), bindings=ChannelBinding( amqp=amqp.ChannelBinding( diff --git a/faststream/specification/asyncapi/message.py b/faststream/specification/asyncapi/message.py index 6d208ea2c84..d1a2e06f703 100644 --- a/faststream/specification/asyncapi/message.py +++ b/faststream/specification/asyncapi/message.py @@ -35,6 +35,7 @@ def parse_handler_params(call: "CallModel", prefix: str = "") -> dict[str, Any]: return body + def parse_handler_return(call: "CallModel", prefix: str = "") -> dict[str, Any]: """Parses the handler parameters.""" model_container = getattr(call, "serializer", call) diff --git a/faststream/specification/asyncapi/v3_0_0/generate.py b/faststream/specification/asyncapi/v3_0_0/generate.py index 442cda404ae..847136496df 100644 --- a/faststream/specification/asyncapi/v3_0_0/generate.py +++ b/faststream/specification/asyncapi/v3_0_0/generate.py @@ -1,7 +1,7 @@ import string import warnings from collections.abc import Sequence -from typing import Callable, TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Optional, Union from urllib.parse import urlparse from faststream._internal._compat import DEF_KEY @@ -17,20 +17,22 @@ License, Message, Operation, + OperationReply, Reference, Server, - OperationReply, Tag, ) from faststream.specification.asyncapi.v3_0_0.schema.bindings import ( OperationBinding, http as http_bindings, ) -from faststream.specification.asyncapi.v3_0_0.schema.operation_reply import OperationReplyAddress +from faststream.specification.asyncapi.v3_0_0.schema.operation_reply import ( + OperationReplyAddress, +) from faststream.specification.asyncapi.v3_0_0.schema.operations import Action if TYPE_CHECKING: - from faststream._internal.basic_types import AnyHttpUrl + from faststream._internal.basic_types import AnyCallable, AnyHttpUrl from faststream._internal.broker import BrokerUsecase from faststream._internal.types import ConnectionType, MsgType from faststream.asgi.handlers import HttpHandler @@ -97,8 +99,10 @@ def get_app_schema( if not operation.reply: continue for message in operation.reply.messages: - reply_msgs['ReplyMessage'] = _resolve_reply_payloads( - f'{operation_name.removesuffix("Subscribe")}:ReplyMessage', + assert isinstance(message, Message) + + reply_msgs["ReplyMessage"] = _resolve_reply_payloads( + f"{operation_name.removesuffix('Subscribe')}:ReplyMessage", message, payloads, reply_messages, @@ -184,7 +188,7 @@ def get_broker_channels( """Get the broker channels for an application.""" channels = {} operations = {} - operations_by_handler: dict[Callable, Operation] = {} + operations_by_handler: dict[AnyCallable, Operation] = {} for sub in filter(lambda s: s.specification.include_in_schema, broker.subscribers): for sub_key, sub_channel in sub.schema().items(): @@ -210,13 +214,17 @@ def get_broker_channels( channel=Reference(**{"$ref": f"#/channels/{channel_key}"}), operation=sub_channel.operation, reply=OperationReply( - messages=[Message.from_spec(sub_channel.operation.reply_message)] if sub_channel.operation.reply_message else [], + messages=[Message.from_spec(sub_channel.operation.reply_message)] + if sub_channel.operation.reply_message + else [], address=OperationReplyAddress( description=None, location="$message.header#/replyTo", ), channel=None, - ) if not sub._no_reply else None, + ) + if not sub._no_reply + else None, ) operations[f"{channel_key}Subscribe"] = operation for call in sub.specification.calls: @@ -249,7 +257,9 @@ def get_broker_channels( sub_operation = operations_by_handler.get(call) if sub_operation is None or sub_operation.reply is None: continue - sub_operation.reply.channel = Reference(**{"$ref": f"#/channels/{channel_key}"}) + sub_operation.reply.channel = Reference(**{ + "$ref": f"#/channels/{channel_key}" + }) return channels, operations @@ -291,11 +301,12 @@ def get_asgi_routes( def _get_http_binding_method(methods: Sequence[str]) -> str: return next((method for method in methods if method != "HEAD"), "HEAD") + def _resolve_reply_payloads( - message_name: str, - m: Message, - payloads: dict[str, Any], - reply_messages: dict[str, Any], + message_name: str, + m: Message, + payloads: dict[str, Any], + reply_messages: dict[str, Any], ) -> Reference: assert isinstance(m.payload, dict) @@ -347,6 +358,7 @@ def _resolve_reply_payloads( **{"$ref": f"#/components/messages/{message_name}"}, ) + def _resolve_msg_payloads( message_name: str, m: Message, diff --git a/faststream/specification/asyncapi/v3_0_0/schema/__init__.py b/faststream/specification/asyncapi/v3_0_0/schema/__init__.py index 42b5c3a3c19..317f57b7506 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/__init__.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/__init__.py @@ -5,8 +5,8 @@ from .info import ApplicationInfo from .license import License from .message import CorrelationId, Message -from .operations import Operation from .operation_reply import OperationReply +from .operations import Operation from .schema import ApplicationSchema from .servers import Server, ServerVariable from .tag import Tag diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py index 323038b73de..4cc9fb59857 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/operation_reply.py @@ -10,6 +10,7 @@ class OperationReplyAddress(BaseModel): description: str | None = None location: str + class OperationReply(BaseModel): messages: list[Message | Reference] channel: Reference | None = None @@ -18,5 +19,6 @@ class OperationReply(BaseModel): if PYDANTIC_V2: model_config = {"extra": "allow"} else: + class Config: extra = "allow" diff --git a/faststream/specification/schema/reply/model.py b/faststream/specification/schema/reply/model.py index 449518fa9d1..3ec3441cfaf 100644 --- a/faststream/specification/schema/reply/model.py +++ b/faststream/specification/schema/reply/model.py @@ -8,6 +8,7 @@ class OperationReplyAddress: location: str description: str | None = None + @dataclass class OperationReply: message: Message | None From 961c2868a9ef0327d5b17860f4638f09dd108e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sat, 1 Nov 2025 17:01:01 +0300 Subject: [PATCH 08/13] chore: extract _resolve_payloads_common as common part --- .../specification/asyncapi/v3_0_0/generate.py | 110 ++++++++---------- 1 file changed, 46 insertions(+), 64 deletions(-) diff --git a/faststream/specification/asyncapi/v3_0_0/generate.py b/faststream/specification/asyncapi/v3_0_0/generate.py index 847136496df..515a9c94b8d 100644 --- a/faststream/specification/asyncapi/v3_0_0/generate.py +++ b/faststream/specification/asyncapi/v3_0_0/generate.py @@ -302,18 +302,18 @@ def _get_http_binding_method(methods: Sequence[str]) -> str: return next((method for method in methods if method != "HEAD"), "HEAD") -def _resolve_reply_payloads( - message_name: str, - m: Message, +def _resolve_payloads_common( + *, + m: "Message", payloads: dict[str, Any], - reply_messages: dict[str, Any], -) -> Reference: + messages_target: dict[str, Any], + message_ref: str, + default_payload_title: str, +) -> "Reference": assert isinstance(m.payload, dict) m.payload = move_pydantic_refs(m.payload, DEF_KEY) - message_name = clear_key(message_name) - if DEF_KEY in m.payload: payloads.update(m.payload.pop(DEF_KEY)) @@ -328,19 +328,24 @@ def _resolve_reply_payloads( defs = payload.pop(DEF_KEY) or {} for def_name, def_schema in defs.items(): payloads[clear_key(def_name)] = def_schema + processed_payloads[clear_key(name)] = payload - one_of_list.append(Reference(**{"$ref": f"#/components/schemas/{name}"})) + one_of_list.append( + Reference(**{"$ref": f"#/components/schemas/{name}"}) + ) payloads.update(processed_payloads) m.payload["oneOf"] = one_of_list + assert m.title - reply_messages[clear_key(m.title)] = m - return Reference( - **{"$ref": f"#/components/messages/{message_name}"}, - ) + messages_target[clear_key(m.title)] = m + + return Reference(**{"$ref": message_ref}) + payloads.update(m.payload.pop(DEF_KEY, {})) - payload_name = m.payload.get("title", f"{message_name}:Payload") + + payload_name = m.payload.get("title", default_payload_title) payload_name = clear_key(payload_name) if payload_name in payloads and payloads[payload_name] != m.payload: @@ -352,67 +357,44 @@ def _resolve_reply_payloads( payloads[payload_name] = m.payload m.payload = {"$ref": f"#/components/schemas/{payload_name}"} + assert m.title - reply_messages[clear_key(m.title)] = m - return Reference( - **{"$ref": f"#/components/messages/{message_name}"}, + messages_target[clear_key(m.title)] = m + + return Reference(**{"$ref": message_ref}) + + +def _resolve_reply_payloads( + message_name: str, + m: "Message", + payloads: dict[str, Any], + reply_messages: dict[str, Any], +) -> "Reference": + message_name = clear_key(message_name) + + return _resolve_payloads_common( + m=m, + payloads=payloads, + messages_target=reply_messages, + message_ref=f"#/components/messages/{message_name}", + default_payload_title=f"{message_name}:Payload", ) def _resolve_msg_payloads( message_name: str, - m: Message, + m: "Message", channel_name: str, payloads: dict[str, Any], messages: dict[str, Any], -) -> Reference: - assert isinstance(m.payload, dict) - - m.payload = move_pydantic_refs(m.payload, DEF_KEY) - +) -> "Reference": message_name = clear_key(message_name) channel_name = clear_key(channel_name) - if DEF_KEY in m.payload: - payloads.update(m.payload.pop(DEF_KEY)) - - one_of = m.payload.get("oneOf", None) - if isinstance(one_of, dict): - one_of_list = [] - processed_payloads: dict[str, dict[str, Any]] = {} - for name, payload in one_of.items(): - # Promote nested Pydantic $defs from each payload into components/schemas - # so that referenced nested models are available globally. - if isinstance(payload, dict) and DEF_KEY in payload: - defs = payload.pop(DEF_KEY) or {} - for def_name, def_schema in defs.items(): - payloads[clear_key(def_name)] = def_schema - processed_payloads[clear_key(name)] = payload - one_of_list.append(Reference(**{"$ref": f"#/components/schemas/{name}"})) - - payloads.update(processed_payloads) - m.payload["oneOf"] = one_of_list - assert m.title - messages[clear_key(m.title)] = m - return Reference( - **{"$ref": f"#/components/messages/{channel_name}:{message_name}"}, - ) - - payloads.update(m.payload.pop(DEF_KEY, {})) - payload_name = m.payload.get("title", f"{channel_name}:{message_name}:Payload") - payload_name = clear_key(payload_name) - - if payload_name in payloads and payloads[payload_name] != m.payload: - warnings.warn( - f"Overwriting the message schema, data types have the same name: `{payload_name}`", - RuntimeWarning, - stacklevel=1, - ) - - payloads[payload_name] = m.payload - m.payload = {"$ref": f"#/components/schemas/{payload_name}"} - assert m.title - messages[clear_key(m.title)] = m - return Reference( - **{"$ref": f"#/components/messages/{channel_name}:{message_name}"}, + return _resolve_payloads_common( + m=m, + payloads=payloads, + messages_target=messages, + message_ref=f"#/components/messages/{channel_name}:{message_name}", + default_payload_title=f"{channel_name}:{message_name}:Payload", ) From bf9ce34ffdea3b293b3702f6f42d146e07652f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sun, 2 Nov 2025 13:50:46 +0300 Subject: [PATCH 09/13] chore: made reply not required parameter --- faststream/specification/asyncapi/v3_0_0/schema/operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/faststream/specification/asyncapi/v3_0_0/schema/operations.py b/faststream/specification/asyncapi/v3_0_0/schema/operations.py index 3bcf57e60cc..7bdfe9b2f44 100644 --- a/faststream/specification/asyncapi/v3_0_0/schema/operations.py +++ b/faststream/specification/asyncapi/v3_0_0/schema/operations.py @@ -65,7 +65,7 @@ def from_sub( messages: list[Reference], channel: Reference, operation: OperationSpec, - reply: OperationReply | None, + reply: OperationReply | None = None, ) -> Self: return cls( action=Action.RECEIVE, From 9c823dec43e8d6b8ebd730048fb842ac0be1914c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Tue, 3 Mar 2026 20:35:01 +0300 Subject: [PATCH 10/13] feat: Add RPC example with AsyncAPI specification and enhance operation key generation for subscribers. --- asyncapi.yaml | 127 ++++++++++++++++++ .../specification/asyncapi/v3_0_0/generate.py | 15 ++- rpc.py | 31 +++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 asyncapi.yaml create mode 100644 rpc.py diff --git a/asyncapi.yaml b/asyncapi.yaml new file mode 100644 index 00000000000..e3df05c7cee --- /dev/null +++ b/asyncapi.yaml @@ -0,0 +1,127 @@ +info: + title: FastStream + version: 0.1.0 +asyncapi: 3.0.0 +defaultContentType: application/json +servers: + development: + host: guest:guest@localhost:5672 + pathname: / + protocol: amqp + protocolVersion: 0.9.1 +channels: + RPC:_:RpcHandler: + address: RPC:_:RpcHandler + description: This queue is used to for RPC + servers: + - $ref: '#/servers/development' + messages: + SubscribeMessage: + $ref: '#/components/messages/RPC:_:RpcHandler:SubscribeMessage' + bindings: + amqp: + is: queue + bindingVersion: 0.3.0 + queue: + name: RPC + durable: false + exclusive: false + autoDelete: false + vhost: / + RPC_OUT:_:Publisher: + address: RPC_OUT:_:Publisher + description: This queue is used to for RPC + servers: + - $ref: '#/servers/development' + messages: + Message: + $ref: '#/components/messages/RPC_OUT:_:Publisher:Message' + bindings: + amqp: + is: routingKey + bindingVersion: 0.3.0 + exchange: + type: default + vhost: / +operations: + RPC:_:RpcHandlerSubscribe: + action: receive + channel: + $ref: '#/channels/RPC:_:RpcHandler' + bindings: + amqp: + cc: + - RPC + ack: true + bindingVersion: 0.3.0 + messages: + - $ref: '#/channels/RPC:_:RpcHandler/messages/SubscribeMessage' + reply: + messages: + - $ref: '#/components/messages/RPC:_:RpcHandler:ReplyMessage' + channel: + $ref: '#/channels/RPC_OUT:_:Publisher' + address: + location: $message.header#/replyTo + RPC_OUT:_:Publisher: + action: send + channel: + $ref: '#/channels/RPC_OUT:_:Publisher' + bindings: + amqp: + cc: + - RPC_OUT + ack: true + deliveryMode: 1 + mandatory: true + bindingVersion: 0.3.0 + messages: + - $ref: '#/channels/RPC_OUT:_:Publisher/messages/Message' +components: + messages: + RPC:_:RpcHandler:SubscribeMessage: + title: RPC:_:RpcHandler:SubscribeMessage + correlationId: + location: $message.header#/correlation_id + payload: + $ref: '#/components/schemas/In' + RPC_OUT:_:Publisher:Message: + title: RPC_OUT:_:Publisher:Message + correlationId: + location: $message.header#/correlation_id + payload: + $ref: '#/components/schemas/Out' + RPC:_:RpcHandler:ReplyMessage: + title: RPC:_:RpcHandler:ReplyMessage + correlationId: + location: $message.header#/correlation_id + payload: + $ref: '#/components/schemas/Out' + schemas: + In: + properties: + i: + title: I + type: string + j: + exclusiveMinimum: 0 + title: J + type: integer + required: + - i + - j + title: In + type: object + Out: + properties: + k: + title: K + type: string + l: + title: L + type: number + required: + - k + - l + title: Out + type: object diff --git a/faststream/specification/asyncapi/v3_0_0/generate.py b/faststream/specification/asyncapi/v3_0_0/generate.py index 515a9c94b8d..94c74fd4fdf 100644 --- a/faststream/specification/asyncapi/v3_0_0/generate.py +++ b/faststream/specification/asyncapi/v3_0_0/generate.py @@ -204,6 +204,19 @@ def get_broker_channels( channels[channel_key] = channel_obj + operation_key = ( + f"{channel_key}Subscribe" + if sub.specification.config.title_ is None + or sub.specification.config.title_ == "/" + else sub.specification.config.title_ + ) + if operation_key in operations: + warnings.warn( + f"Overwrite channel handler, operations have the same names: `{operation_key}`", + RuntimeWarning, + stacklevel=1, + ) + operation = Operation.from_sub( messages=[ Reference(**{ @@ -226,7 +239,7 @@ def get_broker_channels( if not sub._no_reply else None, ) - operations[f"{channel_key}Subscribe"] = operation + operations[operation_key] = operation for call in sub.specification.calls: operations_by_handler[call.handler._original_call] = operation diff --git a/rpc.py b/rpc.py new file mode 100644 index 00000000000..b8715c7f7f0 --- /dev/null +++ b/rpc.py @@ -0,0 +1,31 @@ +import logging + +from faststream import FastStream +from faststream.rabbit import RabbitBroker +from pydantic import BaseModel, conint + +broker = RabbitBroker() +app = FastStream(broker) + + +class In(BaseModel): + i: str + j: conint(gt=0) + + + +class In2(BaseModel): + i: str + j: conint(gt=0) + + +class Out(BaseModel): + k: str + l: float + + +@broker.subscriber("RPC", description="This queue is used to for RPC") +@broker.publisher("RPC_OUT", description="This queue is used to for RPC") +async def rpc_handler(input: In) -> Out: + logging.info(f"{input=}") + return Out(k=input.i, l=input.j) \ No newline at end of file From 69c051dabf2acb92b9409113f1fff1fbc988e37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Tue, 3 Mar 2026 20:41:43 +0300 Subject: [PATCH 11/13] refactor: remove RPC example implementation and its AsyncAPI specification. --- asyncapi.yaml | 127 -------------------------------------------------- rpc.py | 31 ------------ 2 files changed, 158 deletions(-) delete mode 100644 asyncapi.yaml delete mode 100644 rpc.py diff --git a/asyncapi.yaml b/asyncapi.yaml deleted file mode 100644 index e3df05c7cee..00000000000 --- a/asyncapi.yaml +++ /dev/null @@ -1,127 +0,0 @@ -info: - title: FastStream - version: 0.1.0 -asyncapi: 3.0.0 -defaultContentType: application/json -servers: - development: - host: guest:guest@localhost:5672 - pathname: / - protocol: amqp - protocolVersion: 0.9.1 -channels: - RPC:_:RpcHandler: - address: RPC:_:RpcHandler - description: This queue is used to for RPC - servers: - - $ref: '#/servers/development' - messages: - SubscribeMessage: - $ref: '#/components/messages/RPC:_:RpcHandler:SubscribeMessage' - bindings: - amqp: - is: queue - bindingVersion: 0.3.0 - queue: - name: RPC - durable: false - exclusive: false - autoDelete: false - vhost: / - RPC_OUT:_:Publisher: - address: RPC_OUT:_:Publisher - description: This queue is used to for RPC - servers: - - $ref: '#/servers/development' - messages: - Message: - $ref: '#/components/messages/RPC_OUT:_:Publisher:Message' - bindings: - amqp: - is: routingKey - bindingVersion: 0.3.0 - exchange: - type: default - vhost: / -operations: - RPC:_:RpcHandlerSubscribe: - action: receive - channel: - $ref: '#/channels/RPC:_:RpcHandler' - bindings: - amqp: - cc: - - RPC - ack: true - bindingVersion: 0.3.0 - messages: - - $ref: '#/channels/RPC:_:RpcHandler/messages/SubscribeMessage' - reply: - messages: - - $ref: '#/components/messages/RPC:_:RpcHandler:ReplyMessage' - channel: - $ref: '#/channels/RPC_OUT:_:Publisher' - address: - location: $message.header#/replyTo - RPC_OUT:_:Publisher: - action: send - channel: - $ref: '#/channels/RPC_OUT:_:Publisher' - bindings: - amqp: - cc: - - RPC_OUT - ack: true - deliveryMode: 1 - mandatory: true - bindingVersion: 0.3.0 - messages: - - $ref: '#/channels/RPC_OUT:_:Publisher/messages/Message' -components: - messages: - RPC:_:RpcHandler:SubscribeMessage: - title: RPC:_:RpcHandler:SubscribeMessage - correlationId: - location: $message.header#/correlation_id - payload: - $ref: '#/components/schemas/In' - RPC_OUT:_:Publisher:Message: - title: RPC_OUT:_:Publisher:Message - correlationId: - location: $message.header#/correlation_id - payload: - $ref: '#/components/schemas/Out' - RPC:_:RpcHandler:ReplyMessage: - title: RPC:_:RpcHandler:ReplyMessage - correlationId: - location: $message.header#/correlation_id - payload: - $ref: '#/components/schemas/Out' - schemas: - In: - properties: - i: - title: I - type: string - j: - exclusiveMinimum: 0 - title: J - type: integer - required: - - i - - j - title: In - type: object - Out: - properties: - k: - title: K - type: string - l: - title: L - type: number - required: - - k - - l - title: Out - type: object diff --git a/rpc.py b/rpc.py deleted file mode 100644 index b8715c7f7f0..00000000000 --- a/rpc.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging - -from faststream import FastStream -from faststream.rabbit import RabbitBroker -from pydantic import BaseModel, conint - -broker = RabbitBroker() -app = FastStream(broker) - - -class In(BaseModel): - i: str - j: conint(gt=0) - - - -class In2(BaseModel): - i: str - j: conint(gt=0) - - -class Out(BaseModel): - k: str - l: float - - -@broker.subscriber("RPC", description="This queue is used to for RPC") -@broker.publisher("RPC_OUT", description="This queue is used to for RPC") -async def rpc_handler(input: In) -> Out: - logging.info(f"{input=}") - return Out(k=input.i, l=input.j) \ No newline at end of file From e805006a3ff1ae95abe287181ae0e6b69c260732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sun, 12 Apr 2026 02:56:13 +0300 Subject: [PATCH 12/13] chore: move operation to separate variable --- faststream/specification/asyncapi/v3_0_0/generate.py | 7 ++----- uv.lock | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/faststream/specification/asyncapi/v3_0_0/generate.py b/faststream/specification/asyncapi/v3_0_0/generate.py index db7b0513a02..4982d7a2522 100644 --- a/faststream/specification/asyncapi/v3_0_0/generate.py +++ b/faststream/specification/asyncapi/v3_0_0/generate.py @@ -217,7 +217,7 @@ def get_broker_channels( stacklevel=1, ) - operations[operation_key] = Operation.from_sub( + operation = Operation.from_sub( messages=[ Reference(**{ "$ref": f"#/channels/{channel_key}/messages/{msg_name}", @@ -343,9 +343,7 @@ def _resolve_payloads_common( payloads[clear_key(def_name)] = def_schema processed_payloads[clear_key(name)] = payload - one_of_list.append( - Reference(**{"$ref": f"#/components/schemas/{name}"}) - ) + one_of_list.append(Reference(**{"$ref": f"#/components/schemas/{name}"})) payloads.update(processed_payloads) m.payload["oneOf"] = one_of_list @@ -355,7 +353,6 @@ def _resolve_payloads_common( return Reference(**{"$ref": message_ref}) - payloads.update(m.payload.pop(DEF_KEY, {})) payload_name = m.payload.get("title", default_payload_title) diff --git a/uv.lock b/uv.lock index f7067b55f26..c3338f4ef9d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -850,7 +850,7 @@ wheels = [ [[package]] name = "faststream" -version = "0.6.7" +version = "0.7.0rc0" source = { editable = "." } dependencies = [ { name = "anyio" }, From cd35d3fdd0851ff889e9445e2421ce0355c2407c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D1=82=D0=B5=D1=84=D0=B0=D0=BD=20=C2=ABGambit=C2=BB?= =?UTF-8?q?=20=D0=92=D0=B0=D1=81=D0=B8=D0=BB=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Sun, 12 Apr 2026 03:51:24 +0300 Subject: [PATCH 13/13] fix: fix all tests --- tests/asyncapi/base/v3_0_0/arguments.py | 239 +++++++++--------- tests/asyncapi/base/v3_0_0/naming.py | 102 +++++--- tests/asyncapi/base/v3_0_0/router.py | 3 +- .../asyncapi/confluent/v3_0_0/test_naming.py | 20 +- .../asyncapi/confluent/v3_0_0/test_router.py | 18 ++ tests/asyncapi/kafka/v3_0_0/test_naming.py | 20 +- tests/asyncapi/kafka/v3_0_0/test_router.py | 18 ++ tests/asyncapi/kafka/v3_0_0/test_security.py | 22 ++ tests/asyncapi/nats/v3_0_0/test_naming.py | 20 +- tests/asyncapi/nats/v3_0_0/test_router.py | 18 ++ tests/asyncapi/rabbit/v3_0_0/test_naming.py | 21 +- tests/asyncapi/rabbit/v3_0_0/test_router.py | 18 ++ tests/asyncapi/redis/v3_0_0/test_naming.py | 22 +- tests/asyncapi/redis/v3_0_0/test_router.py | 18 ++ 14 files changed, 403 insertions(+), 156 deletions(-) diff --git a/tests/asyncapi/base/v3_0_0/arguments.py b/tests/asyncapi/base/v3_0_0/arguments.py index 9c4b0cce953..1f4e39ff7b5 100644 --- a/tests/asyncapi/base/v3_0_0/arguments.py +++ b/tests/asyncapi/base/v3_0_0/arguments.py @@ -107,12 +107,11 @@ async def handle() -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "EmptyPayload" - assert v == { - "title": key, - "type": "null", - } + assert "EmptyPayload" in payload + assert payload["EmptyPayload"] == { + "title": "EmptyPayload", + "type": "null", + } def test_no_type(self) -> None: broker = self.broker_class() @@ -124,9 +123,8 @@ async def handle(msg) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - assert v == {"title": key} + assert "Handle:Message:Payload" in payload + assert payload["Handle:Message:Payload"] == {"title": "Handle:Message:Payload"} def test_simple_type(self) -> None: broker = self.broker_class() @@ -139,9 +137,8 @@ async def handle(msg: int) -> None: ... payload = schema["components"]["schemas"] assert next(iter(schema["channels"].values())).get("description") is None - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - assert v == {"title": key, "type": "integer"} + assert "Handle:Message:Payload" in payload + assert payload["Handle:Message:Payload"] == {"title": "Handle:Message:Payload", "type": "integer"} def test_simple_optional_type(self) -> None: broker = self.broker_class() @@ -153,19 +150,20 @@ async def handle(msg: int | None) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - assert v == IsDict( - { - "anyOf": [{"type": "integer"}, {"type": "null"}], - "title": key, - }, - ) | IsDict( - { # TODO: remove when deprecating PydanticV1 - "title": key, - "type": "integer", - }, - ), v + key = "Handle:Message:Payload" + assert key in payload + v = payload[key] + assert v == IsDict( + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": key, + }, + ) | IsDict( + { # TODO: remove when deprecating PydanticV1 + "title": key, + "type": "integer", + }, + ), v def test_simple_type_with_default(self) -> None: broker = self.broker_class() @@ -177,13 +175,13 @@ async def handle(msg: int = 1) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - assert v == { - "default": 1, - "title": key, - "type": "integer", - } + key = "Handle:Message:Payload" + assert key in payload + assert payload[key] == { + "default": 1, + "title": key, + "type": "integer", + } def test_multi_args_no_type(self) -> None: broker = self.broker_class() @@ -195,17 +193,17 @@ async def handle(msg, another) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - assert v == { - "properties": { - "another": {"title": "Another"}, - "msg": {"title": "Msg"}, - }, - "required": ["msg", "another"], - "title": key, - "type": "object", - } + key = "Handle:Message:Payload" + assert key in payload + assert payload[key] == { + "properties": { + "another": {"title": "Another"}, + "msg": {"title": "Msg"}, + }, + "required": ["msg", "another"], + "title": key, + "type": "object", + } def test_multi_args_with_type(self) -> None: broker = self.broker_class() @@ -217,17 +215,17 @@ async def handle(msg: str, another: int) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - assert v == { - "properties": { - "another": {"title": "Another", "type": "integer"}, - "msg": {"title": "Msg", "type": "string"}, - }, - "required": ["msg", "another"], - "title": key, - "type": "object", - } + key = "Handle:Message:Payload" + assert key in payload + assert payload[key] == { + "properties": { + "another": {"title": "Another", "type": "integer"}, + "msg": {"title": "Msg", "type": "string"}, + }, + "required": ["msg", "another"], + "title": key, + "type": "object", + } def test_multi_args_with_default(self) -> None: broker = self.broker_class() @@ -239,30 +237,31 @@ async def handle(msg: str, another: int | None = None) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - - assert v == { - "properties": { - "another": IsDict( - { - "anyOf": [{"type": "integer"}, {"type": "null"}], - "default": None, - "title": "Another", - }, - ) - | IsDict( - { # TODO: remove when deprecating PydanticV1 - "title": "Another", - "type": "integer", - }, - ), - "msg": {"title": "Msg", "type": "string"}, - }, - "required": ["msg"], - "title": key, - "type": "object", - } + key = "Handle:Message:Payload" + assert key in payload + v = payload[key] + + assert v == { + "properties": { + "another": IsDict( + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": None, + "title": "Another", + }, + ) + | IsDict( + { # TODO: remove when deprecating PydanticV1 + "title": "Another", + "type": "integer", + }, + ), + "msg": {"title": "Msg", "type": "string"}, + }, + "required": ["msg"], + "title": key, + "type": "object", + } def test_dataclass(self) -> None: @dataclass @@ -280,6 +279,8 @@ async def handle(user: User) -> None: ... payload = schema["components"]["schemas"] for key, v in payload.items(): + if key == "EmptyPayload" or key.endswith(":ReplyMessage:Payload"): + continue assert key == "User" assert v == { "properties": { @@ -306,6 +307,8 @@ async def handle(user: User) -> None: ... payload = schema["components"]["schemas"] for key, v in payload.items(): + if key == "EmptyPayload" or key.endswith(":ReplyMessage:Payload"): + continue assert key == "User" assert v == { "properties": { @@ -336,7 +339,7 @@ async def handle(user: User) -> None: ... payload = schema["components"]["schemas"] - assert payload == { + assert IsPartialDict({ "Status": IsPartialDict( { "enum": ["registered", "banned"], @@ -354,7 +357,7 @@ async def handle(user: User) -> None: ... "title": "User", "type": "object", }, - }, payload + }) == payload, payload def test_pydantic_model_mixed_regular(self) -> None: class Email(pydantic.BaseModel): @@ -374,7 +377,7 @@ async def handle(user: User, description: str = "") -> None: ... payload = schema["components"]["schemas"] - assert payload == { + assert IsPartialDict({ "Email": { "title": "Email", "type": "object", @@ -404,7 +407,7 @@ async def handle(user: User, description: str = "") -> None: ... }, "required": ["user"], }, - } + }) == payload def test_nested_models_in_union_should_be_in_schemas(self) -> None: """Test that nested Pydantic models in union types are promoted to components/schemas. @@ -507,18 +510,18 @@ async def handle(user: User) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "User" - assert v == { - "examples": [{"id": 1, "name": "john"}], - "properties": { - "id": {"title": "Id", "type": "integer"}, - "name": {"default": "", "title": "Name", "type": "string"}, - }, - "required": ["id"], - "title": "User", - "type": "object", - } + key = "User" + assert key in payload + assert payload[key] == { + "examples": [{"id": 1, "name": "john"}], + "properties": { + "id": {"title": "Id", "type": "integer"}, + "name": {"default": "", "title": "Name", "type": "string"}, + }, + "required": ["id"], + "title": "User", + "type": "object", + } def test_with_filter(self) -> None: class User(pydantic.BaseModel): @@ -570,18 +573,18 @@ async def handle(id: int, message=message) -> None: ... payload = schema["components"]["schemas"] - for key, v in payload.items(): - assert key == "Handle:Message:Payload" - assert v == { - "properties": { - "id": {"title": "Id", "type": "integer"}, - "name": {"default": "", "title": "Name", "type": "string"}, - "name2": {"title": "Name2", "type": "string"}, - }, - "required": ["id", "name2"], - "title": key, - "type": "object", - }, v + key = "Handle:Message:Payload" + assert key in payload + assert payload[key] == { + "properties": { + "id": {"title": "Id", "type": "integer"}, + "name": {"default": "", "title": "Name", "type": "string"}, + "name2": {"title": "Name2", "type": "string"}, + }, + "required": ["id", "name2"], + "title": key, + "type": "object", + }, payload[key] @pydantic_v2 def test_discriminator(self) -> None: @@ -674,15 +677,15 @@ async def handle(user: Model) -> None: ... key = next(iter(schema["components"]["messages"].keys())) assert key == IsStr(regex=r"test[\w:]*:Handle:SubscribeMessage") - assert schema["components"] == { - "messages": { + assert schema["components"] == IsPartialDict({ + "messages": IsPartialDict({ key: { "title": key, "correlationId": {"location": "$message.header#/correlation_id"}, "payload": {"$ref": "#/components/schemas/Model"}, }, - }, - "schemas": { + }), + "schemas": IsPartialDict({ "Sub": { "properties": { "type": IsPartialDict({"const": "sub", "title": "Type"}), @@ -714,8 +717,8 @@ async def handle(user: Model) -> None: ... "title": "Model", "type": "object", }, - }, - }, schema["components"] + }), + }), schema["components"] class ArgumentsTestcase(FastAPICompatible): @@ -739,6 +742,8 @@ async def msg( payload = schema["components"]["schemas"] for key, v in payload.items(): + if key == "EmptyPayload" or key.endswith(":ReplyMessage:Payload"): + continue assert key == "Perfect" assert v == { @@ -765,6 +770,8 @@ async def handle( payload = schema["components"]["schemas"] for key, v in payload.items(): + if key == "EmptyPayload" or key.endswith(":ReplyMessage:Payload"): + continue assert v == IsDict( { "properties": { @@ -822,11 +829,11 @@ async def second_handle(user: User) -> None: ... payload = schema["components"]["schemas"] - assert len(payload) == 1 - - key, value = next(iter(payload.items())) + assert len(payload) == 3 - assert key == "User" + assert "User" in payload + key = "User" + value = payload[key] assert value == { "properties": IsDict({ "id": {"title": "Id", "type": "integer"}, diff --git a/tests/asyncapi/base/v3_0_0/naming.py b/tests/asyncapi/base/v3_0_0/naming.py index 30e347e80b8..74dad573f24 100644 --- a/tests/asyncapi/base/v3_0_0/naming.py +++ b/tests/asyncapi/base/v3_0_0/naming.py @@ -25,13 +25,15 @@ async def handle_user_created(msg: str) -> None: ... IsStr(regex=r"test[\w:]*:HandleUserCreated"), ] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( IsStr(regex=r"test[\w:]*:HandleUserCreated:SubscribeMessage"), - ] + IsStr(regex=r"test[\w:]*:HandleUserCreated:ReplyMessage"), + ) & HasLen(2) - assert list(schema["components"]["schemas"].keys()) == [ + assert list(schema["components"]["schemas"].keys()) == Contains( "HandleUserCreated:Message:Payload", - ] + "HandleUserCreated:ReplyMessage:Payload", + ) & HasLen(2) def test_pydantic_subscriber_naming(self) -> None: broker = self.broker_class() @@ -45,11 +47,15 @@ async def handle_user_created(msg: create_model("SimpleModel")) -> None: ... IsStr(regex=r"test[\w:]*:HandleUserCreated"), ] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( IsStr(regex=r"test[\w:]*:HandleUserCreated:SubscribeMessage"), - ] + IsStr(regex=r"test[\w:]*:HandleUserCreated:ReplyMessage"), + ) & HasLen(2) - assert list(schema["components"]["schemas"].keys()) == ["SimpleModel"] + assert list(schema["components"]["schemas"].keys()) == Contains( + "SimpleModel", + "HandleUserCreated:ReplyMessage:Payload", + ) & HasLen(2) def test_multi_subscribers_naming(self) -> None: broker = self.broker_class() @@ -68,11 +74,14 @@ async def handle_user_created(msg: str) -> None: ... assert list(schema["components"]["messages"].keys()) == Contains( IsStr(regex=r"test[\w:]*:HandleUserCreated:SubscribeMessage"), IsStr(regex=r"test2[\w:]*:HandleUserCreated:SubscribeMessage"), - ) & HasLen(2) + IsStr(regex=r"test[\w:]*:HandleUserCreated:ReplyMessage"), + IsStr(regex=r"test2[\w:]*:HandleUserCreated:ReplyMessage"), + ) & HasLen(4) - assert list(schema["components"]["schemas"].keys()) == [ + assert list(schema["components"]["schemas"].keys()) == Contains( "HandleUserCreated:Message:Payload", - ] + "HandleUserCreated:ReplyMessage:Payload", + ) & HasLen(2) def test_subscriber_naming_manual(self) -> None: broker = self.broker_class() @@ -84,13 +93,15 @@ async def handle_user_created(msg: str) -> None: ... assert list(schema["channels"].keys()) == ["custom"] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( "custom:SubscribeMessage", - ] + "custom:ReplyMessage", + ) & HasLen(2) - assert list(schema["components"]["schemas"].keys()) == [ + assert list(schema["components"]["schemas"].keys()) == Contains( "custom:Message:Payload", - ] + "custom:ReplyMessage:Payload", + ) & HasLen(2) def test_subscriber_naming_default(self) -> None: broker = self.broker_class() @@ -103,11 +114,14 @@ def test_subscriber_naming_default(self) -> None: IsStr(regex=r"test[\w:]*:Subscriber"), ] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( IsStr(regex=r"test[\w:]*:Subscriber:SubscribeMessage"), - ] + IsStr(regex=r"test[\w:]*:Subscriber:ReplyMessage"), + ) & HasLen(2) for key, v in schema["components"]["schemas"].items(): + if key == "Subscriber:ReplyMessage:Payload": + continue assert key == "Subscriber:Message:Payload" assert v == {"title": key} @@ -120,13 +134,15 @@ def test_subscriber_naming_default_with_title(self) -> None: assert list(schema["channels"].keys()) == ["custom"] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( "custom:SubscribeMessage", - ] + "custom:ReplyMessage", + ) & HasLen(2) - assert list(schema["components"]["schemas"].keys()) == [ + assert list(schema["components"]["schemas"].keys()) == Contains( "custom:Message:Payload", - ] + "custom:ReplyMessage:Payload", + ) & HasLen(2) assert schema["components"]["schemas"]["custom:Message:Payload"] == { "title": "custom:Message:Payload", @@ -153,12 +169,17 @@ async def handle_user_created(msg: str) -> None: ... IsStr(regex=r"test[\w:]*:HandleUserCreated:SubscribeMessage"), IsStr(regex=r"test2[\w:]*:Subscriber:SubscribeMessage"), IsStr(regex=r"test3[\w:]*:Subscriber:SubscribeMessage"), - ) & HasLen(3) + IsStr(regex=r"test[\w:]*:HandleUserCreated:ReplyMessage"), + IsStr(regex=r"test2[\w:]*:Subscriber:ReplyMessage"), + IsStr(regex=r"test3[\w:]*:Subscriber:ReplyMessage"), + ) & HasLen(6) assert list(schema["components"]["schemas"].keys()) == Contains( "HandleUserCreated:Message:Payload", "Subscriber:Message:Payload", - ) & HasLen(2) + "HandleUserCreated:ReplyMessage:Payload", + "Subscriber:ReplyMessage:Payload", + ) & HasLen(4) assert schema["components"]["schemas"]["Subscriber:Message:Payload"] == { "title": "Subscriber:Message:Payload", @@ -183,16 +204,21 @@ async def handle_user_id(msg: int) -> None: ... IsStr(regex=r"test[\w:]*:\[HandleUserCreated,HandleUserId\]"), ] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( IsStr( regex=r"test[\w:]*:\[HandleUserCreated,HandleUserId\]:SubscribeMessage", ), - ] + IsStr( + regex=r"test[\w:]*:\[HandleUserCreated,HandleUserId\]:ReplyMessage", + ), + ) & HasLen(2) - assert list(schema["components"]["schemas"].keys()) == [ + assert list(schema["components"]["schemas"].keys()) == Contains( "HandleUserCreated:Message:Payload", "HandleUserId:Message:Payload", - ] + "HandleUserCreated:ReplyMessage:Payload", + "HandleUserId:ReplyMessage:Payload", + ) & HasLen(4) def test_subscriber_filter_pydantic(self) -> None: broker = self.broker_class() @@ -211,16 +237,21 @@ async def handle_user_id(msg: int) -> None: ... IsStr(regex=r"test[\w:]*:\[HandleUserCreated,HandleUserId\]"), ] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( IsStr( regex=r"test[\w:]*:\[HandleUserCreated,HandleUserId\]:SubscribeMessage", ), - ] + IsStr( + regex=r"test[\w:]*:\[HandleUserCreated,HandleUserId\]:ReplyMessage", + ), + ) & HasLen(2) - assert list(schema["components"]["schemas"].keys()) == [ + assert list(schema["components"]["schemas"].keys()) == Contains( "SimpleModel", "HandleUserId:Message:Payload", - ] + "HandleUserCreated:ReplyMessage:Payload", + "HandleUserId:ReplyMessage:Payload", + ) & HasLen(4) def test_subscriber_filter_with_title(self) -> None: broker = self.broker_class() @@ -237,14 +268,17 @@ async def handle_user_id(msg: int) -> None: ... assert list(schema["channels"].keys()) == ["custom"] - assert list(schema["components"]["messages"].keys()) == [ + assert list(schema["components"]["messages"].keys()) == Contains( "custom:SubscribeMessage", - ] + "custom:ReplyMessage", + ) & HasLen(2) - assert list(schema["components"]["schemas"].keys()) == [ + assert list(schema["components"]["schemas"].keys()) == Contains( "HandleUserCreated:Message:Payload", "HandleUserId:Message:Payload", - ] + "HandleUserCreated:ReplyMessage:Payload", + "HandleUserId:ReplyMessage:Payload", + ) & HasLen(4) class PublisherNaming(BaseNaming): diff --git a/tests/asyncapi/base/v3_0_0/router.py b/tests/asyncapi/base/v3_0_0/router.py index ebcb531c1b1..af57476c106 100644 --- a/tests/asyncapi/base/v3_0_0/router.py +++ b/tests/asyncapi/base/v3_0_0/router.py @@ -30,6 +30,7 @@ async def handle(msg) -> None: ... schema = self.get_spec(broker).to_jsonable() payload = schema["components"]["schemas"] + payload = {k: v for k, v in payload.items() if k != "EmptyPayload"} key = list(payload.keys())[0] # noqa: RUF015 assert payload[key]["title"] == key == "Handle:Message:Payload" @@ -52,7 +53,7 @@ async def handle(msg) -> None: ... schema = self.get_spec(broker).to_jsonable() schemas = schema["components"]["schemas"] - del schemas["Handle:Message:Payload"] + schemas = {k: v for k, v in schemas.items() if k not in ("Handle:Message:Payload", "EmptyPayload", "Handle:ReplyMessage:Payload")} for i, j in schemas.items(): assert ( diff --git a/tests/asyncapi/confluent/v3_0_0/test_naming.py b/tests/asyncapi/confluent/v3_0_0/test_naming.py index 41b6de66645..27a7464312c 100644 --- a/tests/asyncapi/confluent/v3_0_0/test_naming.py +++ b/tests/asyncapi/confluent/v3_0_0/test_naming.py @@ -55,6 +55,16 @@ async def handle() -> None: ... "$ref": "#/channels/test:Handle/messages/SubscribeMessage", }, ], + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -66,7 +76,15 @@ async def handle() -> None: ... }, "payload": {"$ref": "#/components/schemas/EmptyPayload"}, }, + "test:Handle:ReplyMessage": { + "title": "test:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, - "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}}, + "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}}, }, } diff --git a/tests/asyncapi/confluent/v3_0_0/test_router.py b/tests/asyncapi/confluent/v3_0_0/test_router.py index c7a8f672d42..1dde62eaa9f 100644 --- a/tests/asyncapi/confluent/v3_0_0/test_router.py +++ b/tests/asyncapi/confluent/v3_0_0/test_router.py @@ -64,6 +64,16 @@ async def handle(msg) -> None: ... }, ], "channel": {"$ref": "#/channels/test_test:Handle"}, + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test_test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -77,9 +87,17 @@ async def handle(msg) -> None: ... "$ref": "#/components/schemas/Handle:Message:Payload", }, }, + "test_test:Handle:ReplyMessage": { + "title": "test_test:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, "schemas": { "Handle:Message:Payload": {"title": "Handle:Message:Payload"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, }, }, } diff --git a/tests/asyncapi/kafka/v3_0_0/test_naming.py b/tests/asyncapi/kafka/v3_0_0/test_naming.py index f3dd6b9244f..bcacdba9029 100644 --- a/tests/asyncapi/kafka/v3_0_0/test_naming.py +++ b/tests/asyncapi/kafka/v3_0_0/test_naming.py @@ -55,6 +55,16 @@ async def handle() -> None: ... "$ref": "#/channels/test:Handle/messages/SubscribeMessage", }, ], + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -66,8 +76,16 @@ async def handle() -> None: ... }, "payload": {"$ref": "#/components/schemas/EmptyPayload"}, }, + "test:Handle:ReplyMessage": { + "title": "test:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, - "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}}, + "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}}, }, } diff --git a/tests/asyncapi/kafka/v3_0_0/test_router.py b/tests/asyncapi/kafka/v3_0_0/test_router.py index a26549c820a..1ff628ee708 100644 --- a/tests/asyncapi/kafka/v3_0_0/test_router.py +++ b/tests/asyncapi/kafka/v3_0_0/test_router.py @@ -64,6 +64,16 @@ async def handle(msg) -> None: ... }, ], "channel": {"$ref": "#/channels/test_test:Handle"}, + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test_test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -77,9 +87,17 @@ async def handle(msg) -> None: ... "$ref": "#/components/schemas/Handle:Message:Payload", }, }, + "test_test:Handle:ReplyMessage": { + "title": "test_test:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, "schemas": { "Handle:Message:Payload": {"title": "Handle:Message:Payload"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, }, }, } diff --git a/tests/asyncapi/kafka/v3_0_0/test_security.py b/tests/asyncapi/kafka/v3_0_0/test_security.py index de3ceecd1f6..93d0c29af72 100644 --- a/tests/asyncapi/kafka/v3_0_0/test_security.py +++ b/tests/asyncapi/kafka/v3_0_0/test_security.py @@ -54,6 +54,17 @@ {"$ref": "#/channels/test_1:TestTopic/messages/SubscribeMessage"}, ], "channel": {"$ref": "#/channels/test_1:TestTopic"}, + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "channel": {"$ref": "#/channels/test_2:Publisher"}, + "messages": [ + { + "$ref": "#/components/messages/test_1:TestTopic:ReplyMessage", + }, + ], + }, }, "test_2:Publisher": { "action": "send", @@ -68,6 +79,13 @@ "correlationId": {"location": "$message.header#/correlation_id"}, "payload": {"$ref": "#/components/schemas/TestTopic:Message:Payload"}, }, + "test_1:TestTopic:ReplyMessage": { + "title": "test_1:TestTopic:ReplyMessage", + "correlationId": {"location": "$message.header#/correlation_id"}, + "payload": { + "$ref": "#/components/schemas/TestTopic:ReplyMessage:Payload", + }, + }, "test_2:Publisher:Message": { "title": "test_2:Publisher:Message", "correlationId": {"location": "$message.header#/correlation_id"}, @@ -81,6 +99,10 @@ "title": "TestTopic:Message:Payload", "type": "string", }, + "TestTopic:ReplyMessage:Payload": { + "title": "TestTopic:ReplyMessage:Payload", + "type": "string", + }, "test_2:Publisher:Message:Payload": { "title": "test_2:Publisher:Message:Payload", "type": "string", diff --git a/tests/asyncapi/nats/v3_0_0/test_naming.py b/tests/asyncapi/nats/v3_0_0/test_naming.py index 79db9e2abcd..abfb0ba5f40 100644 --- a/tests/asyncapi/nats/v3_0_0/test_naming.py +++ b/tests/asyncapi/nats/v3_0_0/test_naming.py @@ -57,6 +57,16 @@ async def handle() -> None: ... "$ref": "#/channels/test:Handle/messages/SubscribeMessage", }, ], + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -68,7 +78,15 @@ async def handle() -> None: ... }, "payload": {"$ref": "#/components/schemas/EmptyPayload"}, }, + "test:Handle:ReplyMessage": { + "title": "test:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, - "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}}, + "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}}, }, } diff --git a/tests/asyncapi/nats/v3_0_0/test_router.py b/tests/asyncapi/nats/v3_0_0/test_router.py index cd965d72986..3ed37affdf6 100644 --- a/tests/asyncapi/nats/v3_0_0/test_router.py +++ b/tests/asyncapi/nats/v3_0_0/test_router.py @@ -64,6 +64,16 @@ async def handle(msg) -> None: ... }, ], "channel": {"$ref": "#/channels/test_test:Handle"}, + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test_test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -77,9 +87,17 @@ async def handle(msg) -> None: ... "$ref": "#/components/schemas/Handle:Message:Payload", }, }, + "test_test:Handle:ReplyMessage": { + "title": "test_test:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, "schemas": { "Handle:Message:Payload": {"title": "Handle:Message:Payload"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, }, }, } diff --git a/tests/asyncapi/rabbit/v3_0_0/test_naming.py b/tests/asyncapi/rabbit/v3_0_0/test_naming.py index 02d4c766ca8..a3e01d31640 100644 --- a/tests/asyncapi/rabbit/v3_0_0/test_naming.py +++ b/tests/asyncapi/rabbit/v3_0_0/test_naming.py @@ -20,6 +20,7 @@ async def handle() -> None: ... assert list(schema["components"]["messages"].keys()) == [ "test:exchange:Handle:SubscribeMessage", + "test:exchange:Handle:ReplyMessage", ] def test_publisher_with_exchange(self) -> None: @@ -104,6 +105,16 @@ async def handle() -> None: ... "$ref": "#/channels/test:_:Handle/messages/SubscribeMessage", }, ], + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test:_:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -115,7 +126,15 @@ async def handle() -> None: ... }, "payload": {"$ref": "#/components/schemas/EmptyPayload"}, }, + "test:_:Handle:ReplyMessage": { + "title": "test:_:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, - "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}}, + "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}}, }, } diff --git a/tests/asyncapi/rabbit/v3_0_0/test_router.py b/tests/asyncapi/rabbit/v3_0_0/test_router.py index c7a9590e6c7..73745a57ac4 100644 --- a/tests/asyncapi/rabbit/v3_0_0/test_router.py +++ b/tests/asyncapi/rabbit/v3_0_0/test_router.py @@ -89,6 +89,16 @@ async def handle(msg) -> None: ... }, ], "channel": {"$ref": "#/channels/test_test:_:Handle"}, + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test_test:_:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -102,9 +112,17 @@ async def handle(msg) -> None: ... "$ref": "#/components/schemas/Handle:Message:Payload", }, }, + "test_test:_:Handle:ReplyMessage": { + "title": "test_test:_:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, "schemas": { "Handle:Message:Payload": {"title": "Handle:Message:Payload"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, }, }, }, schema diff --git a/tests/asyncapi/redis/v3_0_0/test_naming.py b/tests/asyncapi/redis/v3_0_0/test_naming.py index 76497118028..e5b2f15b109 100644 --- a/tests/asyncapi/redis/v3_0_0/test_naming.py +++ b/tests/asyncapi/redis/v3_0_0/test_naming.py @@ -45,6 +45,16 @@ async def handle() -> None: ... "messages": [ {"$ref": "#/channels/test:Handle/messages/SubscribeMessage"}, ], + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -56,8 +66,18 @@ async def handle() -> None: ... "payload": {"$ref": "#/components/schemas/EmptyPayload"}, "title": "test:Handle:SubscribeMessage", }, + "test:Handle:ReplyMessage": { + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + "title": "test:Handle:ReplyMessage", + }, + }, + "schemas": { + "EmptyPayload": {"title": "EmptyPayload", "type": "null"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, }, - "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}}, }, "defaultContentType": "application/json", "info": {"title": "FastStream", "version": "0.1.0"}, diff --git a/tests/asyncapi/redis/v3_0_0/test_router.py b/tests/asyncapi/redis/v3_0_0/test_router.py index 3a668c90fc6..d0e029dc008 100644 --- a/tests/asyncapi/redis/v3_0_0/test_router.py +++ b/tests/asyncapi/redis/v3_0_0/test_router.py @@ -68,6 +68,16 @@ async def handle(msg) -> None: ... }, ], "channel": {"$ref": "#/channels/test_test:Handle"}, + "reply": { + "address": { + "location": "$message.header#/replyTo", + }, + "messages": [ + { + "$ref": "#/components/messages/test_test:Handle:ReplyMessage", + }, + ], + }, }, }, "components": { @@ -81,9 +91,17 @@ async def handle(msg) -> None: ... "$ref": "#/components/schemas/Handle:Message:Payload", }, }, + "test_test:Handle:ReplyMessage": { + "title": "test_test:Handle:ReplyMessage", + "correlationId": { + "location": "$message.header#/correlation_id", + }, + "payload": {"$ref": "#/components/schemas/Handle:ReplyMessage:Payload"}, + }, }, "schemas": { "Handle:Message:Payload": {"title": "Handle:Message:Payload"}, + "Handle:ReplyMessage:Payload": {"title": "Handle:ReplyMessage:Payload", "type": "null"}, }, }, }