Skip to content

Commit 60d69b0

Browse files
committed
feat: Implement simplify_ids in OpenAPIScanner for shorter module IDs, supported by new helper methods and get_scanner kwargs.
1 parent dcbafa6 commit 60d69b0

File tree

6 files changed

+145
-23
lines changed

6 files changed

+145
-23
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.2.0] - 2026-03-18
6+
7+
### Added
8+
- **`simplify_ids` option for `OpenAPIScanner`** -- when enabled, generates clean module IDs using only the function name instead of the full FastAPI operationId. For example, `product.get_product_product__product_id_.get` becomes `product.get_product.get`. Defaults to `False` for backward compatibility.
9+
- **`_extract_func_name()` static method** -- reverses FastAPI's `generate_unique_id()` transformation to recover the original Python function name from an operationId. Handles path parameters, nested paths, and hyphens correctly.
10+
- **`_strip_method_suffix()` static method** -- minimal default simplification that removes only the trailing `_{method}` from operationIds.
11+
- **`get_scanner()` now accepts `**kwargs`** -- keyword arguments are forwarded to the scanner constructor, enabling `get_scanner("openapi", simplify_ids=True)`.
12+
- **5 new tests** for `simplify_ids` behavior -- covers shortened IDs, function name extraction, no duplicates, default mode preserving path info, and factory kwarg passthrough.
13+
14+
### Changed
15+
- `_generate_module_id()` refactored -- uses `_extract_func_name()` when `simplify_ids=True`, or `_strip_method_suffix()` when `False`. Extracted common prefix logic to reduce duplication.
16+
- Module docstring in `openapi.py` updated to document both ID generation modes.
17+
518
## [0.1.0] - 2026-03-18
619

720
### Added

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ FastAPI integration for [apcore](https://github.com/aipartnerup/apcore-python) (
88
- **Annotation inference** -- `GET` -> readonly+cacheable, `DELETE` -> destructive, `PUT` -> idempotent
99
- **Pydantic schema extraction** -- input/output schemas extracted from Pydantic models and OpenAPI spec
1010
- **Two scanner backends** -- OpenAPI-based (accurate) and native route inspection (fast)
11+
- **Simplified module IDs** -- `simplify_ids=True` extracts clean function names from FastAPI operationIds
1112
- **`@module` decorator** -- define standalone AI-callable modules with full schema enforcement
1213
- **YAML binding** -- zero-code module definitions via external `.binding.yaml` files
1314
- **MCP server** -- stdio, streamable-http, and SSE transports via `fastapi-apcore serve`
@@ -380,15 +381,22 @@ Two scanner backends are available:
380381
```python
381382
from fastapi_apcore import get_scanner
382383
383-
# OpenAPI scanner (default)
384+
# OpenAPI scanner (default) -- full operationId-based IDs
384385
scanner = get_scanner("openapi")
385386
modules = scanner.scan(app)
386387
388+
# OpenAPI scanner with simplified IDs (recommended for CLI)
389+
scanner = get_scanner("openapi", simplify_ids=True)
390+
modules = scanner.scan(app)
391+
# product.get_product_product__product_id_.get → product.get_product.get
392+
387393
# Native scanner
388394
scanner = get_scanner("native")
389395
modules = scanner.scan(app, include=r"users\.", exclude=r"\.delete$")
390396
```
391397

398+
The `simplify_ids` option extracts the original Python function name from FastAPI's auto-generated operationId, producing much shorter and more readable module IDs. It defaults to `False` for backward compatibility.
399+
392400
## Project Structure
393401

394402
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "fastapi-apcore"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "FastAPI integration for apcore AI-Perceivable Core — exposes FastAPI routes as apcore modules with auto-discovery, schema extraction from Pydantic models, and OpenAPI-based scanning."
99
requires-python = ">=3.11"
1010
readme = "README.md"

src/fastapi_apcore/scanners/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING
5+
from typing import TYPE_CHECKING, Any
66

77
from fastapi_apcore.scanners.native import NativeFastAPIScanner
88
from fastapi_apcore.scanners.openapi import OpenAPIScanner
@@ -16,12 +16,14 @@
1616
}
1717

1818

19-
def get_scanner(source: str = "openapi") -> BaseScanner:
19+
def get_scanner(source: str = "openapi", **kwargs: Any) -> BaseScanner:
2020
"""Instantiate a scanner by source name.
2121
2222
Args:
2323
source: Scanner type — 'native' for route inspection,
2424
'openapi' for OpenAPI schema-based scanning (default).
25+
**kwargs: Passed to the scanner constructor.
26+
For 'openapi': ``simplify_ids=True`` generates simplified IDs.
2527
2628
Returns:
2729
A BaseScanner subclass instance.
@@ -33,4 +35,4 @@ def get_scanner(source: str = "openapi") -> BaseScanner:
3335
if scanner_cls is None:
3436
valid = ", ".join(sorted(_SCANNER_REGISTRY))
3537
raise ValueError(f"Unknown scanner source '{source}'. Valid sources: {valid}")
36-
return scanner_cls()
38+
return scanner_cls(**kwargs)

src/fastapi_apcore/scanners/openapi.py

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
This is the most accurate scanner since FastAPI's OpenAPI generation
55
handles all edge cases (Depends, File, Form, etc.).
66
7-
Module ID format: {tag}.{operation_id}.{method}
7+
Module ID format:
8+
Default: {tag}.{operationId_without_method}.{method}
9+
simplify_ids: {tag}.{func_name}.{method}
810
"""
911

1012
from __future__ import annotations
@@ -34,6 +36,17 @@ class OpenAPIScanner(BaseScanner):
3436
- Response models with $ref
3537
"""
3638

39+
def __init__(self, *, simplify_ids: bool = False) -> None:
40+
"""Initialize the OpenAPI scanner.
41+
42+
Args:
43+
simplify_ids: When True, generate simplified module IDs using only
44+
the function name (e.g. ``product.get_product.get``).
45+
When False (default), use the full FastAPI operationId
46+
(e.g. ``product.get_product_product__product_id_.get``).
47+
"""
48+
self._simplify_ids = simplify_ids
49+
3750
def scan(
3851
self,
3952
app: FastAPI,
@@ -108,39 +121,72 @@ def get_source_name(self) -> str:
108121
return "openapi-fastapi"
109122

110123
def _generate_module_id(self, operation: dict[str, Any], path: str, method: str) -> str:
111-
"""Generate module_id from tags + operation_id + method.
124+
"""Generate module_id from tags + function_name + method.
125+
126+
When ``simplify_ids=True`` (set in constructor), extracts the clean
127+
function name from FastAPI's operationId::
112128
113-
Format: {tag}.{operation_id}.{method}
114-
Falls back to path-based ID if no tags.
129+
GET /product/{product_id} → product.get_product.get
130+
POST /task/create → task.create_task.post
131+
132+
When ``simplify_ids=False`` (default), uses the raw operationId
133+
with only the trailing method stripped::
134+
135+
GET /product/{product_id} → product.get_product_product__product_id_.get
115136
"""
116137
operation_id: str = operation.get("operationId", "unknown")
117138
tags = operation.get("tags", [])
118139

119-
# Clean operation_id: FastAPI generates e.g. "create_task_task_create_post"
120-
# We use the simpler function name portion
121-
func_name = self._simplify_operation_id(operation_id)
140+
if self._simplify_ids:
141+
func_name = self._extract_func_name(operation_id, path, method)
142+
else:
143+
func_name = self._strip_method_suffix(operation_id, method)
122144

123145
if tags:
124146
prefix = str(tags[0]).lower().replace(" ", "_")
125-
module_id = f"{prefix}.{func_name}.{method.lower()}"
126147
else:
127148
path_parts = [p for p in path.strip("/").split("/") if not p.startswith("{")]
128149
prefix = ".".join(path_parts) if path_parts else "root"
129-
module_id = f"{prefix}.{func_name}.{method.lower()}"
130150

151+
module_id = f"{prefix}.{func_name}.{method.lower()}"
131152
return re.sub(r"[^a-zA-Z0-9._]", "_", module_id)
132153

133-
def _simplify_operation_id(self, operation_id: str) -> str:
134-
"""Simplify FastAPI's auto-generated operation IDs.
154+
@staticmethod
155+
def _strip_method_suffix(operation_id: str, method: str) -> str:
156+
"""Strip the trailing ``_{method}`` from an operationId.
157+
158+
This is the default (non-short) simplification — removes only the
159+
HTTP method suffix while preserving the full path information.
160+
"""
161+
suffix = f"_{method.lower()}"
162+
if operation_id.endswith(suffix):
163+
return operation_id[: -len(suffix)]
164+
return operation_id
165+
166+
@staticmethod
167+
def _extract_func_name(operation_id: str, path: str, method: str) -> str:
168+
"""Extract the original function name from a FastAPI operationId.
169+
170+
FastAPI generates operationId as::
171+
172+
re.sub(r"\\W", "_", f"{func_name}{path}") + "_{method}"
135173
136-
FastAPI generates IDs like 'create_task_task_create_post'.
137-
We strip the trailing path+method suffix to get the function name.
174+
This method reverses that transformation to recover ``func_name``.
138175
"""
139-
# FastAPI pattern: {func_name}_{path_part}_{path_part}_{method}
140-
# Try to extract just the function name by removing the suffix
141-
parts = operation_id.rsplit("_", 1)
142-
if len(parts) == 2 and parts[1] in ("get", "post", "put", "delete", "patch"):
143-
return parts[0]
176+
method_lower = method.lower()
177+
178+
# Reconstruct the path suffix exactly as FastAPI generates it:
179+
# every non-word character (\W) in the path becomes "_"
180+
path_suffix = re.sub(r"\W", "_", path)
181+
expected_suffix = f"{path_suffix}_{method_lower}"
182+
183+
if operation_id.endswith(expected_suffix):
184+
return operation_id[: -len(expected_suffix)].rstrip("_")
185+
186+
# Fallback: strip trailing _{method}
187+
if operation_id.endswith(f"_{method_lower}"):
188+
return operation_id[: -(len(method_lower) + 1)]
189+
144190
return operation_id
145191

146192
def _build_view_map(self, app: FastAPI) -> dict[str, str]:

tests/test_scanner.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,59 @@ def test_metadata_has_source(self, app: FastAPI) -> None:
215215
assert "operation_id" in m.metadata
216216

217217

218+
# -- OpenAPIScanner simplify_ids --------------------------------------------
219+
220+
221+
class TestOpenAPIScannerSimplifyIds:
222+
def test_simplified_ids_are_shorter(self, app: FastAPI) -> None:
223+
default = OpenAPIScanner()
224+
simplified = OpenAPIScanner(simplify_ids=True)
225+
226+
default_modules = default.scan(app)
227+
simplified_modules = simplified.scan(app)
228+
229+
assert len(default_modules) == len(simplified_modules)
230+
for d, s in zip(default_modules, simplified_modules):
231+
assert len(s.module_id) <= len(d.module_id)
232+
233+
def test_simplified_ids_use_func_name(self, app: FastAPI) -> None:
234+
scanner = OpenAPIScanner(simplify_ids=True)
235+
modules = scanner.scan(app)
236+
ids = {m.module_id for m in modules}
237+
238+
# Should contain clean function-name based IDs
239+
assert "items.list_items.get" in ids
240+
assert "items.create_item.post" in ids
241+
assert "items.get_item.get" in ids
242+
assert "items.delete_item.delete" in ids
243+
244+
def test_default_ids_contain_path_info(self, app: FastAPI) -> None:
245+
scanner = OpenAPIScanner(simplify_ids=False)
246+
modules = scanner.scan(app)
247+
ids = {m.module_id for m in modules}
248+
249+
# Default IDs should contain path fragments (longer)
250+
get_detail = next(mid for mid in ids if "get_item" in mid and mid.endswith(".get"))
251+
assert "item_id" in get_detail # path param info preserved
252+
253+
def test_simplify_ids_no_duplicates(self, app: FastAPI) -> None:
254+
scanner = OpenAPIScanner(simplify_ids=True)
255+
modules = scanner.scan(app)
256+
ids = [m.module_id for m in modules]
257+
258+
assert len(ids) == len(set(ids)), f"Duplicate IDs found: {ids}"
259+
260+
def test_factory_passes_simplify_ids(self, app: FastAPI) -> None:
261+
from fastapi_apcore.scanners import get_scanner
262+
263+
scanner = get_scanner("openapi", simplify_ids=True)
264+
modules = scanner.scan(app)
265+
266+
# Verify simplified IDs (no path fragments like __item_id__)
267+
for m in modules:
268+
assert "__" not in m.module_id, f"Unsimplified ID: {m.module_id}"
269+
270+
218271
# -- get_scanner factory -----------------------------------------------------
219272

220273

0 commit comments

Comments
 (0)