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' ])
0 commit comments