2727 get_swagger_ui_oauth2_redirect_html ,
2828)
2929from fastapi .openapi .utils import get_openapi
30+ from fastapi .routing import iter_route_contexts
3031from sqlalchemy import text
3132from sqlalchemy .orm import Session
3233
@@ -123,27 +124,32 @@ def public_openapi():
123124 routes = app .routes ,
124125 )
125126
126- # Keep only operations where the endpoint function is marked public.
127+ # Collect the operations whose endpoint carries @in_public_schema.
128+ #
129+ # This walks iter_route_contexts() rather than app.routes. Routes added
130+ # via app.include_router() are not flattened into app.routes -- they
131+ # live inside opaque _IncludedRouter branches -- so the previous
132+ # `next(r for r in app.routes if r.path == path)` lookup matched
133+ # nothing but the few endpoints declared directly on `app`, and
134+ # silently dropped every decorated router route from the public schema.
135+ # iter_route_contexts() is the same helper get_openapi() itself walks,
136+ # so prefixes resolve identically to the paths in `schema`.
137+ public_operations = set ()
138+ for route_context in iter_route_contexts (app .routes ):
139+ if not getattr (route_context .endpoint , "_in_public_schema" , False ):
140+ continue
141+ route_path = route_context .path_format or route_context .path
142+ for route_method in route_context .methods or ():
143+ public_operations .add ((route_path , route_method .lower ()))
144+
127145 new_paths = {}
128146 for path , path_item in schema ["paths" ].items ():
129147 new_methods = {}
130148 for method , operation in path_item .items ():
131- route = next (
132- (
133- r
134- for r in app .routes
135- if getattr (r , "path" , None ) == path
136- and method .upper () in getattr (r , "methods" , set ())
137- ),
138- None ,
139- )
140- if not route :
149+ if (path , method .lower ()) not in public_operations :
141150 continue
142-
143- endpoint = getattr (route , "endpoint" , None )
144- if getattr (endpoint , "_is_public" , False ):
145- operation ["security" ] = []
146- new_methods [method ] = operation
151+ operation ["security" ] = []
152+ new_methods [method ] = operation
147153
148154 if new_methods :
149155 new_paths [path ] = new_methods
@@ -224,7 +230,7 @@ async def warmup():
224230 return {"status" : "ok" }
225231
226232 @app .get ("/health" , tags = ["meta" ])
227- @public_route
233+ @in_public_schema
228234 def health (response : Response , session : Session = Depends (get_db_session )):
229235 # Ping the database so a 200 actually proves PostGIS is reachable, not
230236 # just that the process is up. Uptime monitors / status pages assert on
@@ -248,9 +254,18 @@ def health(response: Response, session: Session = Depends(get_db_session)):
248254 return app
249255
250256
251- def public_route (func ):
252- """Mark a route as public for OpenAPI filtering."""
253- setattr (func , "_is_public" , True )
257+ def in_public_schema (func ):
258+ """Advertise a route in the anonymous OpenAPI schema (/openapi.json).
259+
260+ Schema visibility only -- this grants no access and removes no dependency.
261+ It was previously named `public_route`, which read like an authorization
262+ decorator; two `/thing` endpoints carried it *and* a `viewer_dependency`,
263+ so the public schema advertised operations that 401 for anonymous callers.
264+
265+ Apply it only to routes that genuinely have no auth dependency.
266+ tests/test_authorization.py asserts the two sets match exactly.
267+ """
268+ setattr (func , "_in_public_schema" , True )
254269 return func
255270
256271
0 commit comments