Skip to content

Commit f7ecf98

Browse files
committed
added new powerful decorator (route_path) which will allow override the path (couple use cases need it)
1 parent 05afeb5 commit f7ecf98

6 files changed

Lines changed: 407 additions & 74 deletions

File tree

osbot_fast_api/api/Fast_API__Routes.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,13 @@ def fast_api_utils(self):
300300
from osbot_fast_api.utils.Fast_API_Utils import Fast_API_Utils
301301
return Fast_API_Utils(self.app)
302302

303-
def parse_function_name(self, function): # added support for routes that have resource ids in the path
304-
route_parser = Fast_API__Route__Parser() # Create parser instance
305-
function_path = route_parser.parse_route_path(function)
306-
return function_path
303+
def parse_function_name(self, function): # added support for routes that have resource ids in the path
304+
if hasattr(function, '__route_path__'): # Check for decorator attribute
305+
return function.__route_path__ # Use explicit path
306+
else:
307+
route_parser = Fast_API__Route__Parser() # Create parser instance
308+
function_path = route_parser.parse_route_path(function)
309+
return function_path
307310

308311
@index_by
309312
def routes(self):
@@ -313,7 +316,7 @@ def routes_methods(self):
313316
return list(self.routes(index_by='method_name'))
314317

315318
def routes_paths(self):
316-
return list(self.routes(index_by='http_path'))
319+
return sorted(list(self.routes(index_by='http_path')))
317320

318321
def setup(self):
319322
self.setup_routes()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def route_path(path: str): # Decorator to explicitly set the route path for a function
2+
def decorator(func):
3+
func.__route_path__ = path # Store path as function attribute
4+
return func
5+
return decorator

osbot_fast_api/schemas/Safe_Str__Fast_API__Route__Tag.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from osbot_utils.type_safe.primitives.safe_str.Safe_Str import Safe_Str
44

5-
TYPE_SAFE_STR__FASTAPI__ROUTE__REGEX = re.compile(r'[^a-zA-Z0-9\-_/{}]')
5+
TYPE_SAFE_STR__FASTAPI__ROUTE__REGEX = re.compile(r'[^a-zA-Z0-9\-_/{}.]')
66
TYPE_SAFE_STR__FASTAPI__ROUTE__MAX_LENGTH = 512
77

88
class Safe_Str__FastAPI__Route__Tag(Safe_Str):
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
from unittest import TestCase
2+
from osbot_fast_api.api.Fast_API import Fast_API
3+
from osbot_fast_api.api.Fast_API__Routes import Fast_API__Routes
4+
from osbot_fast_api.api.decorators.route_path import route_path
5+
from osbot_utils.type_safe.Type_Safe import Type_Safe
6+
from osbot_utils.type_safe.primitives.safe_str.Safe_Str import Safe_Str
7+
from osbot_utils.type_safe.primitives.safe_str.identifiers.Safe_Id import Safe_Id
8+
from osbot_utils.type_safe.primitives.safe_str.identifiers.Random_Guid import Random_Guid
9+
from osbot_utils.type_safe.primitives.safe_int.Safe_Int import Safe_Int
10+
from osbot_utils.type_safe.primitives.safe_float.Safe_Float import Safe_Float
11+
12+
13+
class test_route_path(TestCase):
14+
15+
def setUp(self):
16+
self.fast_api_routes = Fast_API__Routes()
17+
18+
def test_route_path_decorator(self):
19+
@route_path("/api/v1/users.json")
20+
def get_users_json(): pass # Would normally be /get-users-json
21+
22+
@route_path("/api/v2/user/{id}/profile.html")
23+
def user_profile_html(id: str): pass # Explicit path with extension
24+
25+
@route_path("/legacy/API/GetUser") # Maintain legacy uppercase path
26+
def get_user_legacy(user_id: str): pass
27+
28+
@route_path("/files/{filename}.{extension}") # Multiple path params with dots
29+
def download_file(filename: str, extension: str): pass
30+
31+
def normal__route__parsing(self): pass # Still works without decorator
32+
33+
with self.fast_api_routes as _:
34+
_.add_routes_get(get_users_json ,
35+
user_profile_html ,
36+
get_user_legacy ,
37+
download_file ,
38+
normal__route__parsing)
39+
40+
assert _.routes_paths() == sorted(['/api/v1/users.json' ,
41+
'/api/v2/user/{id}/profile.html' ,
42+
'/legacy/API/GetUser' ,
43+
'/files/{filename}.{extension}' ,
44+
'/normal/route/parsing' ])
45+
46+
def test_route_path_with_primitive_types(self):
47+
@route_path("/user/{user_id}/balance")
48+
def get_balance(user_id: Safe_Id, include_pending: bool = False):
49+
return {'user_id': str(user_id), 'balance': 100.50, 'pending': include_pending}
50+
51+
@route_path("/api/v1/resource/{guid}.json")
52+
def get_resource_json(guid: Random_Guid):
53+
return {'resource_id': str(guid), 'format': 'json'}
54+
55+
@route_path("/calc/percentage/{value}/{rate}")
56+
def calculate_percentage(value: Safe_Float, rate: Safe_Float):
57+
result = float(value) * float(rate) / 100
58+
return {'value': float(value), 'rate': float(rate), 'result': result}
59+
60+
@route_path("/items/page/{page_num}/size/{page_size}")
61+
def get_paginated_items(page_num: Safe_Int, page_size: Safe_Int):
62+
return {'page': int(page_num), 'size': int(page_size), 'total': int(page_num) * int(page_size)}
63+
64+
with self.fast_api_routes as _:
65+
_.add_routes_get(get_balance ,
66+
get_resource_json ,
67+
calculate_percentage ,
68+
get_paginated_items )
69+
70+
assert _.routes_paths() == sorted(['/user/{user_id}/balance' ,
71+
'/api/v1/resource/{guid}.json' ,
72+
'/calc/percentage/{value}/{rate}' ,
73+
'/items/page/{page_num}/size/{page_size}'])
74+
75+
def test_route_path_with_type_safe_post(self):
76+
class User_Data(Type_Safe):
77+
name : Safe_Str
78+
email : Safe_Str
79+
age : Safe_Int
80+
81+
class Product_Update(Type_Safe):
82+
price : Safe_Float
83+
quantity : Safe_Int
84+
sku : Random_Guid
85+
86+
@route_path("/api/v2/users/create.json")
87+
def create_user_json(user: User_Data):
88+
return {'created': True, 'name': str(user.name), 'format': 'json'}
89+
90+
@route_path("/products/{product_id}/update")
91+
def update_product(product_id: Random_Guid, update: Product_Update):
92+
return {'id': str(product_id), 'new_price': float(update.price)}
93+
94+
@route_path("/LEGACY/API/UpdateUserProfile") # Legacy uppercase endpoint
95+
def legacy_update_profile(user_id: Safe_Id, profile: User_Data):
96+
return {'legacy': True, 'user': str(user_id), 'updated': str(profile.name)}
97+
98+
with self.fast_api_routes as _:
99+
_.add_routes_post(create_user_json ,
100+
update_product ,
101+
legacy_update_profile )
102+
103+
assert _.routes_paths() == sorted(['/api/v2/users/create.json' ,
104+
'/products/{product_id}/update' ,
105+
'/LEGACY/API/UpdateUserProfile' ])
106+
107+
def test_edge_cases_and_special_patterns(self):
108+
@route_path("/") # Root path
109+
def root_handler(): pass
110+
111+
@route_path("//double//slash") # Double slashes (will be normalized by most servers)
112+
def double_slash(): pass
113+
114+
@route_path("/path/with spaces/and-special_chars!@") # Special characters
115+
def special_chars(): pass
116+
117+
@route_path("/very/deep/nested/path/structure/with/many/segments/endpoint.xml") # Deep nesting
118+
def deep_path(): pass
119+
120+
@route_path("/../relative/path") # Relative path (potentially dangerous)
121+
def relative_path(): pass
122+
123+
@route_path("/path/{param1}/{param2}/{param3}/{param4}/{param5}") # Many parameters
124+
def many_params(param1: str, param2: str, param3: str, param4: str, param5: str): pass
125+
126+
@route_path("/mixed_{style}/path-{id}/file.{ext}") # Mixed naming styles
127+
def mixed_styles(style: str, id: Safe_Id, ext: str): pass
128+
129+
with self.fast_api_routes as _:
130+
_.add_routes_get(root_handler ,
131+
double_slash ,
132+
special_chars ,
133+
deep_path ,
134+
relative_path ,
135+
many_params ,
136+
mixed_styles )
137+
138+
assert _.routes_paths() == sorted(['/' ,
139+
'//double//slash' ,
140+
'/path/with spaces/and-special_chars!@' ,
141+
'/very/deep/nested/path/structure/with/many/segments/endpoint.xml',
142+
'/../relative/path' ,
143+
'/path/{param1}/{param2}/{param3}/{param4}/{param5}' ,
144+
'/mixed_{style}/path-{id}/file.{ext}' ])
145+
146+
def test_mixing_decorated_and_auto_generated(self):
147+
@route_path("/custom/path")
148+
def custom_route(): pass
149+
150+
def auto__generated__route(self): pass # Will auto-generate
151+
152+
@route_path("/override/auto/generation")
153+
def should__be__auto__generated(self): pass # Decorator overrides auto-generation
154+
155+
def another__auto_route__with_param(self, param: Safe_Str): pass # Auto with param
156+
157+
@route_path("/files/{user_id}/{filename}.pdf")
158+
def download__user__file(self, user_id: Safe_Id, filename: str): pass # Decorator overrides complex auto-generation
159+
160+
with self.fast_api_routes as _:
161+
_.add_routes_get(custom_route ,
162+
auto__generated__route ,
163+
should__be__auto__generated ,
164+
another__auto_route__with_param ,
165+
download__user__file )
166+
167+
assert _.routes_paths() == sorted(['/custom/path' ,
168+
'/auto/generated/route' ,
169+
'/override/auto/generation' , # Decorator wins
170+
'/another/auto-route/with-param' , # Auto-generated (no param in path)
171+
'/files/{user_id}/{filename}.pdf' ]) # Decorator wins
172+
173+
def test_with_fast_api_client_integration(self):
174+
class Test_Routes(Fast_API__Routes):
175+
tag = 'api'
176+
177+
@route_path("/v1/users.json")
178+
def get_users_json(self):
179+
return {'users': ['alice', 'bob'], 'format': 'json'}
180+
181+
@route_path("/v2/user/{user_id}/profile.xml")
182+
def get_user_profile_xml(self, user_id: Safe_Id):
183+
return {'user_id': str(user_id), 'format': 'xml', 'name': 'User Name'}
184+
185+
@route_path("/download/{filename}.{ext}")
186+
def download_file(self, filename: str, ext: str):
187+
return {'file': f"{filename}.{ext}", 'size': 1024}
188+
189+
@route_path("/calc/tax/{amount}")
190+
def calculate_tax(self, amount: Safe_Float, rate: Safe_Float = Safe_Float(0.1)):
191+
tax = float(amount) * float(rate)
192+
return {'amount': float(amount), 'rate': float(rate), 'tax': tax}
193+
194+
def normal__auto__route(self, param: Safe_Str): # Mix in auto-generated
195+
return {'method': 'auto', 'param': str(param)}
196+
197+
def setup_routes(self):
198+
self.add_routes_get(self.get_users_json ,
199+
self.get_user_profile_xml ,
200+
self.download_file ,
201+
self.calculate_tax ,
202+
self.normal__auto__route )
203+
204+
class Test_Fast_API(Fast_API):
205+
default_routes = False
206+
def setup_routes(self):
207+
self.add_routes(Test_Routes)
208+
209+
fast_api = Test_Fast_API().setup()
210+
client = fast_api.client()
211+
212+
# Test routes are correctly generated
213+
assert fast_api.routes_paths() == sorted(['/api/v1/users.json' ,
214+
'/api/v2/user/{user_id}/profile.xml',
215+
'/api/download/{filename}.{ext}' ,
216+
'/api/calc/tax/{amount}' ,
217+
'/api/normal/auto/route' ])
218+
219+
# Test actual calls
220+
response = client.get('/api/v1/users.json')
221+
assert response.json() == {'users': ['alice', 'bob'], 'format': 'json'}
222+
223+
response = client.get('/api/v2/user/user-123/profile.xml')
224+
assert response.json() == {'user_id': 'user-123', 'format': 'xml', 'name': 'User Name'}
225+
226+
response = client.get('/api/download/report.pdf')
227+
assert response.json() == {'file': 'report.pdf', 'size': 1024}
228+
229+
response = client.get('/api/calc/tax/100', params={'rate': '0.15'})
230+
assert response.json() == {'amount': 100.0, 'rate': 0.15, 'tax': 15.0}
231+
232+
response = client.get('/api/normal/auto/route', params={'param': 'test-value**!!'})
233+
assert response.json() == {'method': 'auto', 'param': 'test_value____'}
234+
235+
def test_post_routes_with_decorator_and_type_safe(self):
236+
class Order_Item(Type_Safe):
237+
product_id : Random_Guid
238+
quantity : Safe_Int
239+
price : Safe_Float
240+
241+
class Order_Request(Type_Safe):
242+
customer_id : Safe_Id
243+
items : list[Order_Item]
244+
notes : Safe_Str
245+
246+
class Test_Routes(Fast_API__Routes):
247+
tag = 'orders'
248+
249+
@route_path("/api/v3/orders/create.json")
250+
def create_order_json(self, order: Order_Request):
251+
total = sum(float(item.price) * int(item.quantity) for item in order.items)
252+
return {'customer': str(order.customer_id), 'total': total, 'format': 'json'}
253+
254+
@route_path("/LEGACY/CreateOrder") # Legacy uppercase
255+
def legacy_create_order(self, customer_id: Safe_Id, order: Order_Request):
256+
return {'legacy': True, 'customer': str(customer_id), 'items': len(order.items)}
257+
258+
@route_path("/orders/{order_id}/items/{item_id}/update.json")
259+
def update_order_item(self, order_id: Random_Guid, item_id: Random_Guid, quantity: Safe_Int):
260+
return {'order': str(order_id), 'item': str(item_id), 'new_quantity': int(quantity)}
261+
262+
def setup_routes(self):
263+
self.add_routes_post(self.create_order_json ,
264+
self.legacy_create_order ,
265+
self.update_order_item )
266+
267+
class Test_Fast_API(Fast_API):
268+
default_routes = False
269+
def setup_routes(self):
270+
self.add_routes(Test_Routes)
271+
272+
fast_api = Test_Fast_API().setup()
273+
client = fast_api.client()
274+
275+
# Test routes
276+
assert fast_api.routes_paths() == sorted(['/orders/api/v3/orders/create.json' ,
277+
'/orders/legacy/createorder' , # the path is always converted to lower (via the Safe_Str__FastAPI__Route__Prefix conversion)
278+
'/orders/orders/{order_id}/items/{item_id}/update.json'])
279+
280+
# Test POST with complex Type_Safe
281+
order_data = Order_Request(
282+
customer_id = Safe_Id('cust-456'),
283+
items = [Order_Item(product_id = Random_Guid('111e8400-e29b-41d4-a716-446655440001'),
284+
quantity = Safe_Int(2),
285+
price = Safe_Float(29.99))],
286+
notes = Safe_Str('Rush delivery')
287+
)
288+
289+
response = client.post('/orders/api/v3/orders/create.json', json=order_data.json())
290+
assert response.json() == {'customer': 'cust-456', 'total': 59.98, 'format': 'json'}
291+
292+
def test_conflicting_paths_and_overrides(self):
293+
@route_path("/users/{id}")
294+
def method_one(id: str): pass
295+
296+
@route_path("/users/{id}") # Same path, different function
297+
def method_two(id: str): pass
298+
299+
@route_path("/items/list")
300+
def items__list(self): pass # Would auto-generate to /items/list anyway
301+
302+
@route_path("/completely/different/path")
303+
def expected__to__be__elsewhere(self): pass # Completely override expected path
304+
305+
with self.fast_api_routes as _:
306+
_.add_routes_get(method_one ,
307+
method_two , # This will override method_one
308+
items__list ,
309+
expected__to__be__elsewhere )
310+
311+
# Note: method_two wins for /users/{id} due to registration order
312+
assert _.routes_paths() == sorted(['/users/{id}' , # Only one registration
313+
'/items/list' ,
314+
'/completely/different/path' ])
315+
316+
def test_decorator_with_query_params_not_in_path(self):
317+
@route_path("/search") # Query params not in path
318+
def search_items(q: str, limit: Safe_Int = Safe_Int(10), offset: Safe_Int = Safe_Int(0)):
319+
return {'query': q, 'limit': int(limit), 'offset': int(offset)}
320+
321+
@route_path("/api/v1/data.csv") # Fixed path, params in query
322+
def export_data_csv(start_date: str, end_date: str, format: str = 'csv'):
323+
return {'start': start_date, 'end': end_date, 'format': format}
324+
325+
with self.fast_api_routes as _:
326+
_.add_routes_get(search_items ,
327+
export_data_csv )
328+
329+
assert _.routes_paths() == sorted(['/search' ,
330+
'/api/v1/data.csv' ])

tests/unit/api/test_Fast_API__Route__Parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest import TestCase
1+
from unittest import TestCase
22
from osbot_fast_api.api.Fast_API__Route__Parser import Fast_API__Route__Parser
33

44

0 commit comments

Comments
 (0)