Skip to content

Commit 975c581

Browse files
authored
✨ Introduce changelog-driven FastAPI route configuration system (#7620)
1 parent adc0218 commit 975c581

File tree

2 files changed

+527
-0
lines changed

2 files changed

+527
-0
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""
2+
CHANGELOG formatted-messages for API routes
3+
4+
- Append at the bottom of the route's description
5+
- These are displayed in the swagger/redoc doc
6+
- These are displayed in client's doc as well (auto-generator)
7+
- Inspired on this idea https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#describing-changes-between-versions
8+
"""
9+
10+
from abc import ABC, abstractmethod
11+
from collections.abc import Sequence
12+
from enum import Enum, auto
13+
from typing import Any, ClassVar, cast
14+
15+
from packaging.version import Version
16+
17+
18+
class ChangelogType(Enum):
19+
"""Types of changelog entries in their lifecycle order"""
20+
21+
NEW = auto()
22+
CHANGED = auto()
23+
DEPRECATED = auto()
24+
RETIRED = auto()
25+
26+
27+
class ChangelogEntryAbstract(ABC):
28+
"""Base class for changelog entries"""
29+
30+
entry_type: ClassVar[ChangelogType]
31+
32+
@abstractmethod
33+
def to_string(self) -> str:
34+
"""Converts entry to a formatted string for documentation"""
35+
36+
@abstractmethod
37+
def get_version(self) -> Version | None:
38+
"""Returns the version associated with this entry, if any"""
39+
40+
41+
class NewEndpoint(ChangelogEntryAbstract):
42+
"""Indicates when an endpoint was first added"""
43+
44+
entry_type = ChangelogType.NEW
45+
46+
def __init__(self, version: str):
47+
self.version = version
48+
49+
def to_string(self) -> str:
50+
return f"New in *version {self.version}*"
51+
52+
def get_version(self) -> Version:
53+
return Version(self.version)
54+
55+
56+
class ChangedEndpoint(ChangelogEntryAbstract):
57+
"""Indicates a change to an existing endpoint"""
58+
59+
entry_type = ChangelogType.CHANGED
60+
61+
def __init__(self, version: str, message: str):
62+
self.version = version
63+
self.message = message
64+
65+
def to_string(self) -> str:
66+
return f"Changed in *version {self.version}*: {self.message}"
67+
68+
def get_version(self) -> Version:
69+
return Version(self.version)
70+
71+
72+
class DeprecatedEndpoint(ChangelogEntryAbstract):
73+
"""Indicates an endpoint is deprecated and should no longer be used"""
74+
75+
entry_type = ChangelogType.DEPRECATED
76+
77+
def __init__(self, alternative_route: str, version: str | None = None):
78+
self.alternative_route = alternative_route
79+
self.version = version
80+
81+
def to_string(self) -> str:
82+
base_message = "🚨 **Deprecated**"
83+
if self.version:
84+
base_message += f" in *version {self.version}*"
85+
86+
return (
87+
f"{base_message}: This endpoint is deprecated and will be removed in a future release.\n"
88+
f"Please use `{self.alternative_route}` instead."
89+
)
90+
91+
def get_version(self) -> Version | None:
92+
return Version(self.version) if self.version else None
93+
94+
95+
class RetiredEndpoint(ChangelogEntryAbstract):
96+
"""Indicates when an endpoint will be or was removed"""
97+
98+
entry_type = ChangelogType.RETIRED
99+
100+
def __init__(self, version: str, message: str):
101+
self.version = version
102+
self.message = message
103+
104+
def to_string(self) -> str:
105+
return f"Retired in *version {self.version}*: {self.message}"
106+
107+
def get_version(self) -> Version:
108+
return Version(self.version)
109+
110+
111+
def create_route_description(
112+
*,
113+
base: str = "",
114+
changelog: Sequence[ChangelogEntryAbstract] | None = None,
115+
) -> str:
116+
"""
117+
Builds a consistent route description with optional changelog information.
118+
119+
Args:
120+
base (str): Main route description.
121+
changelog (Sequence[ChangelogEntry]): List of changelog entries.
122+
123+
Returns:
124+
str: Final description string.
125+
"""
126+
parts = []
127+
128+
if base:
129+
parts.append(base)
130+
131+
if changelog:
132+
# NOTE: Adds a markdown section as : | New in version 0.6.0
133+
changelog_strings = [f"> {entry.to_string()}\n" for entry in changelog]
134+
parts.append("\n".join(changelog_strings))
135+
136+
return "\n".join(parts)
137+
138+
139+
def validate_changelog(changelog: Sequence[ChangelogEntryAbstract]) -> None:
140+
"""
141+
Validates that the changelog entries follow the correct lifecycle order.
142+
143+
Args:
144+
changelog: List of changelog entries to validate
145+
146+
Raises:
147+
ValueError: If the changelog entries are not in a valid order
148+
"""
149+
if not changelog:
150+
return
151+
152+
# Check each entry's type is greater than or equal to the previous
153+
prev_type = None
154+
for entry in changelog:
155+
if prev_type is not None and entry.entry_type.value < prev_type.value:
156+
msg = (
157+
f"Changelog entries must be in lifecycle order. "
158+
f"Found {entry.entry_type.name} after {prev_type.name}."
159+
)
160+
raise ValueError(msg)
161+
prev_type = entry.entry_type
162+
163+
# Ensure there's exactly one NEW entry as the first entry
164+
if changelog and changelog[0].entry_type != ChangelogType.NEW:
165+
msg = "First changelog entry must be NEW type"
166+
raise ValueError(msg)
167+
168+
# Ensure there's at most one DEPRECATED entry
169+
deprecated_entries = [
170+
e for e in changelog if e.entry_type == ChangelogType.DEPRECATED
171+
]
172+
if len(deprecated_entries) > 1:
173+
msg = "Only one DEPRECATED entry is allowed in a changelog"
174+
raise ValueError(msg)
175+
176+
# Ensure all versions are valid
177+
for entry in changelog:
178+
version = entry.get_version()
179+
if version is None and entry.entry_type != ChangelogType.DEPRECATED:
180+
msg = f"Entry of type {entry.entry_type.name} must have a valid version"
181+
raise ValueError(msg)
182+
183+
184+
def create_route_config(
185+
base_description: str = "",
186+
*,
187+
current_version: str | Version,
188+
changelog: Sequence[ChangelogEntryAbstract] | None = None,
189+
) -> dict[str, Any]:
190+
"""
191+
Creates route configuration options including description based on changelog entries.
192+
193+
The function analyzes the changelog to determine if the endpoint:
194+
- Is released and visible (if the earliest entry version is not in the future and not removed)
195+
- Is deprecated (if there's a DEPRECATED entry in the changelog)
196+
197+
Args:
198+
base_description: Main route description
199+
current_version: Current version of the API
200+
changelog: List of changelog entries indicating version history
201+
202+
Returns:
203+
dict: Route configuration options that can be used as kwargs for route decorators
204+
"""
205+
route_options: dict[str, Any] = {}
206+
changelog_list = list(changelog) if changelog else []
207+
208+
validate_changelog(changelog_list)
209+
210+
if isinstance(current_version, str):
211+
current_version = Version(current_version)
212+
213+
# Determine endpoint state
214+
is_deprecated = False
215+
is_released = True # Assume released by default
216+
is_removed = False
217+
218+
# Get the first entry (NEW) to check if released
219+
if changelog_list and changelog_list[0].entry_type == ChangelogType.NEW:
220+
first_entry = cast(NewEndpoint, changelog_list[0])
221+
first_version = first_entry.get_version()
222+
if first_version and first_version > current_version:
223+
is_released = False
224+
225+
# Check for deprecation and removal
226+
for entry in changelog_list:
227+
if entry.entry_type == ChangelogType.DEPRECATED:
228+
is_deprecated = True
229+
elif entry.entry_type == ChangelogType.RETIRED:
230+
is_removed = True
231+
232+
# Set route options based on endpoint state
233+
# An endpoint is included in schema if it's released and not removed
234+
route_options["include_in_schema"] = is_released and not is_removed
235+
route_options["deprecated"] = is_deprecated
236+
237+
# Create description
238+
route_options["description"] = create_route_description(
239+
base=base_description,
240+
changelog=changelog_list,
241+
)
242+
243+
return route_options

0 commit comments

Comments
 (0)