--- name: add-endpoint description: "Add a new FastAPI endpoint to APIView Copilot. Use for: add endpoint, new endpoint, new API route, add route, create endpoint, add API, new POST endpoint, new GET endpoint." --- # Add a FastAPI Endpoint ## Checklist When adding a new endpoint, follow every step below. ### 1. Define Pydantic request/response models - Place models near where they're used — in `app.py` for endpoint-specific models, or in `src/_models.py` for shared/reusable models. - **All multi-word field names MUST use camelCase aliases.** Never expose snake_case in the JSON API. - Add `class Config` with `populate_by_name = True` on any model that has aliases so it can be constructed with either the Python name or the alias. - Use `Field(...)` for required fields, `Field(None, ...)` or `Field(default=..., ...)` for optional ones. - Every model must have a triple-double-quote docstring. #### Example ```python class MyFeatureRequest(BaseModel): """Request model for my feature.""" review_id: str = Field(..., alias="reviewId") language: str include_deleted: bool = Field(False, alias="includeDeleted") max_results: Optional[int] = Field(None, alias="maxResults") class Config: """Configuration for Pydantic model.""" populate_by_name = True class MyFeatureResponse(BaseModel): """Response model for my feature.""" job_id: str = Field(..., alias="jobId") result_count: int = Field(..., alias="resultCount") class Config: """Configuration for Pydantic model.""" populate_by_name = True ``` #### Rules | Rule | Correct | Wrong | |---|---|---| | JSON field casing | `"reviewId"` | `"review_id"` | | Alias declaration | `Field(..., alias="reviewId")` | bare `review_id: str` for multi-word names | | Config on aliased models | `class Config: populate_by_name = True` | missing Config | | Single-word fields | `language: str` (no alias needed) | `language: str = Field(..., alias="language")` | ### 2. Define the endpoint function - Use `@app.post(...)` or `@app.get(...)` etc. with `response_model=` pointing to the response model. - Set `status_code=` when it's not the default 200 (e.g., `202` for async jobs). - Add `Depends(require_roles(...))` for authentication. Use `AppRole.READER` / `AppRole.APP_READER` for read-only, `AppRole.WRITER` / `AppRole.APP_WRITER` for mutations. - Add a docstring describing the endpoint. - Wrap business logic in try/except and raise `HTTPException` with appropriate status codes. - For long-running work, use `asyncio.to_thread(...)` or background tasks. #### Example ```python @app.post("/my-feature", response_model=MyFeatureResponse) async def my_feature( request: MyFeatureRequest, _claims=Depends(require_roles(AppRole.READER, AppRole.APP_READER)), ): """Handle my feature requests.""" try: result = await asyncio.to_thread(do_work, review_id=request.review_id) return MyFeatureResponse(job_id=result.id, result_count=result.count) except Exception as e: logger.error("Error in /my-feature: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") from e ``` ### 3. Serialization FastAPI automatically serializes response models **by alias** when `response_model` is set. This means: - The JSON response will use the alias names (`jobId`, `resultCount`), not the Python names. - No extra `by_alias=True` call is needed — FastAPI handles this via the `response_model`. - When constructing a response object in code, use the **Python field names**: `MyFeatureResponse(job_id=..., result_count=...)`. ### 4. Add a corresponding CLI command Every endpoint must have a CLI command in `cli.py` with a `--remote` flag. The **core logic must be shared** between remote and local paths to the maximum extent practical. #### Architecture: shared core function Extract the business logic into a standalone function (in `src/` or at module level in `cli.py`) that both the endpoint and the CLI's local path call. The CLI's `--remote` path sends an HTTP request to the endpoint instead. ``` ┌─────────────┐ │ core logic │ ← shared function in src/ │ (do_work) │ └──────┬──────┘ │ ┌────────────┴────────────┐ │ │ ┌──────┴──────┐ ┌──────┴──────┐ │ app.py │ │ cli.py │ │ endpoint │ │ (local) │ └─────────────┘ └─────────────┘ │ if --remote: HTTP POST → endpoint ``` #### CLI handler pattern ```python def my_feature(language: str, review_id: str, include_deleted: bool = False, remote: bool = False): """Describe the command.""" if remote: # Remote: HTTP call to the deployed endpoint settings = SettingsManager() base_url = settings.get("WEBAPP_ENDPOINT") payload = {"language": language, "reviewId": review_id, "includeDeleted": include_deleted} resp = requests.post( f"{base_url}/my-feature", json=payload, headers=_build_auth_header(), timeout=60 ) if resp.status_code == 200: print(json.dumps(resp.json(), indent=2)) else: print(f"Error: {resp.status_code} - {resp.text}") else: # Local: call shared core logic directly result = do_work(language=language, review_id=review_id, include_deleted=include_deleted) print(json.dumps(result, indent=2)) ``` Key rules: - The `--remote` payload must use **camelCase** keys matching the endpoint's request model aliases. - Local mode calls the **same core function** that the endpoint calls. - Use `_build_auth_header()` for remote authentication. - Use `SettingsManager().get("WEBAPP_ENDPOINT")` for the base URL. #### Register the command In `CliCommandsLoader.load_command_table`, add the command to the appropriate `CommandGroup`: ```python with CommandGroup(self, "review", "__main__#{}") as g: # ... existing commands ... g.command("my-feature", "my_feature") ``` Register any command-specific arguments in `load_arguments`: ```python with ArgumentsContext(self, "review my-feature") as ac: ac.argument("review_id", options_list=["--review-id", "-r"], help="The review ID.") ac.argument("include_deleted", action="store_true", help="Include deleted items.") ``` Notes: - `--remote` and `--language` are already registered globally — don't re-register them. - Knack maps function parameter names to CLI flags automatically (e.g., `review_id` → `--review-id`). - Use `type=resolve_language_to_canonical` for language params (already global). ### 5. Common pitfalls to avoid - **Never return raw dicts with snake_case keys** from an endpoint. Always use a typed response model. - **Never omit `alias=`** on multi-word field names. The API contract is camelCase. - **Never use `model_config = ConfigDict(alias_generator=to_camel)`** — this project uses explicit `alias=` per field, not automatic generators. - **Never forget `populate_by_name = True`** on models with aliases — without it, the model can't be constructed using Python field names. - **Never duplicate core logic** between the endpoint and the CLI local path. Extract it into a shared function in `src/`. - **Never use snake_case keys in the remote payload** — the `--remote` path must send camelCase keys matching the request model aliases. - **Never re-register `--remote` or `--language`** in command-specific `ArgumentsContext` — they are global.