diff --git a/.gitignore b/.gitignore index e69de29b..1c97ed29 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.venv/ +.pytest_cache/ + +# IDE files +.idea/ diff --git a/README.md b/README.md index 494f1c75..6c217209 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,58 @@ Your solution should include at least one real workflow, for example: - When you are complete, put up a Pull Request against this repository with your changes. - A short summary of your approach and tools used in your PR submission - Any additional information or approach that helped you. + +++++++++++++++++++++++++++++++++++++++++++++++ + +## Local Setup and Usage + +### Clone the Repository +```bash +https://github.com/jeevan-puli/spec-driven-development/tree/resource-management-api +``` +check out the feature branch: +```bash +git checkout resource-management-api +``` + +### Create and Activate Virtual Environment (Python) +```bash +python3 -m venv .venv +source .venv/bin/activate +``` +### Install Dependencies +```bash +pip install -r requirements.txt +``` +### Run the Application +Start the FastAPI server locally: +```bash +uvicorn app.main:app --reload +``` +Verify the API is running by opening: +```bash +http://127.0.0.1:8000/docs +``` + +### You can also manually verify the API: +```bash +curl -X POST http://127.0.0.1:8000/resources \ + -H "Content-Type: application/json" \ + -d '{"name":"example","type":"demo"}' +``` +### Run Tests (without readable logs) +Execute the full test suite: +```bash +pytest +``` +### Run Tests (with readable logs) +Execute the full test suite: +```bash +pytest --log-cli-level=INFO +``` + +## Code Generation Usage +Modern code generation tools (ChatGPT / Codex-style models) were used to +accelerate scaffolding of the API and test cases. All generated code was +reviewed and refined manually to ensure correctness, determinism, and +coverage of edge cases. diff --git a/SPECS/feature-template.md b/SPECS/feature-template.md deleted file mode 100644 index 7dbc70a5..00000000 --- a/SPECS/feature-template.md +++ /dev/null @@ -1,14 +0,0 @@ -# Feature Spec: - -## Goal -- - -## Scope -- In: -- Out: - -## Requirements -- - -## Acceptance Criteria -- [ ] \ No newline at end of file diff --git a/SPECS/resource-managment-api.md b/SPECS/resource-managment-api.md new file mode 100644 index 00000000..2606755b --- /dev/null +++ b/SPECS/resource-managment-api.md @@ -0,0 +1,33 @@ +# Feature Spec: Resource Management API + +## Goal +Provide a simple backend API to create, retrieve, and filter resources in a spec-driven manner, +serving as a test target for API and contract testing. + +## Scope +### In: +- Create a resource via API +- Retrieve all resources +- Filter resources by type +- Validate request and response schemas + +### Out: +- Authentication / authorization +- External persistence (DB, cloud services) +- Frontend UI + +## Requirements +- The API must support creating a resource with required fields. +- Each resource must be assigned a unique ID. +- Resources must be retrievable via a GET endpoint. +- Filtering by resource type must be supported. +- Invalid requests must return clear error responses. +- API responses must conform to a stable schema. + +## Acceptance Criteria +- [ ] POST /resources creates a resource with valid input +- [ ] GET /resources returns all created resources +- [ ] GET /resources?type= filters resources correctly +- [ ] Missing required fields return a 400 error +- [ ] Invalid resource type returns a validation error +- [ ] Response schema remains consistent across requests diff --git a/TODO.md b/TODO.md index b5d82042..764a54e5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ # TODO ## Refactor Proposals -- +- None at this time ## New Feature Proposals -- \ No newline at end of file +- None at this time diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..d542a89d --- /dev/null +++ b/app/main.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Optional +from uuid import uuid4 + +from fastapi import FastAPI, Query +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +app = FastAPI(title="Resource Management API") + +# In-memory storage: id -> resource +resources: dict[str, "Resource"] = {} + + +class ResourceIn(BaseModel): + name: str + type: str + + +class Resource(ResourceIn): + id: str + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + return JSONResponse(status_code=400, content={"detail": "Invalid input"}) + + +@app.post("/resources", response_model=Resource) +def create_resource(payload: ResourceIn) -> Resource: + resource = Resource(id=str(uuid4()), **payload.dict()) + resources[resource.id] = resource + return resource + + +@app.get("/resources", response_model=list[Resource]) +def list_resources(type: Optional[str] = Query(default=None)) -> list[Resource]: + items = list(resources.values()) + if type is not None: + items = [r for r in items if r.type == type] + return items + +@app.get("/health") +def health(): + return {"status": "ok"} + + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..27eec68e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +python_files = test_*.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..2522ad0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +pytest +httpx diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..9e20faf2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +# Add project root to PYTHONPATH +ROOT_DIR = Path(__file__).resolve().parents[1] +sys.path.append(str(ROOT_DIR)) diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py new file mode 100644 index 00000000..58dc98b9 --- /dev/null +++ b/tests/test_resources_api.py @@ -0,0 +1,119 @@ +import logging + +logger = logging.getLogger(__name__) + +from fastapi.testclient import TestClient + +from app.main import app, resources + +client = TestClient(app) + + +def setup_function(): + # Ensure deterministic tests by clearing in-memory storage + resources.clear() + + +def test_create_resource_success(): + logger.info("Creating resource with valid payload") + + response = client.post( + "/resources", + json={"name": "resource-1", "type": "demo"}, + ) + + logger.info("Create response status=%s body=%s", response.status_code, response.json()) + + assert response.status_code == 200 + body = response.json() + + assert "id" in body + assert body["name"] == "resource-1" + assert body["type"] == "demo" + + +def test_list_resources_returns_created_items(): + logger.info("Creating multiple resources for list test") + + client.post("/resources", json={"name": "r1", "type": "demo"}) + client.post("/resources", json={"name": "r2", "type": "test"}) + + logger.info("Fetching all resources") + + response = client.get("/resources") + logger.info("List response status=%s body=%s", response.status_code, response.json()) + + assert response.status_code == 200 + body = response.json() + + assert len(body) == 2 + names = [item["name"] for item in body] + assert "r1" in names + assert "r2" in names + + +def test_filter_resources_by_type(): + logger.info("Creating resources with different types") + + client.post("/resources", json={"name": "r1", "type": "demo"}) + client.post("/resources", json={"name": "r2", "type": "test"}) + + logger.info("Filtering resources by type=demo") + + response = client.get("/resources", params={"type": "demo"}) + logger.info("Filter response status=%s body=%s", response.status_code, response.json()) + + assert response.status_code == 200 + body = response.json() + + assert len(body) == 1 + assert body[0]["name"] == "r1" + assert body[0]["type"] == "demo" + + +def test_create_resource_missing_required_fields_returns_400(): + logger.info("Creating resource with missing required fields") + + response = client.post("/resources", json={}) + logger.info("Validation response status=%s body=%s", response.status_code, response.json()) + + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid input"} + + +def test_create_resource_invalid_payload_returns_400(): + logger.info("Creating resource with invalid payload types") + + response = client.post( + "/resources", + json={"name": 123, "type": ["not", "a", "string"]}, + ) + + logger.info("Invalid payload response status=%s body=%s", response.status_code, response.json()) + + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid input"} + + +def test_response_schema_contains_expected_fields(): + logger.info("Validating response schema for created resource") + + response = client.post( + "/resources", + json={"name": "schema-test", "type": "demo"}, + ) + + body = response.json() + logger.info("Schema validation response body=%s", body) + + assert set(body.keys()) == {"id", "name", "type"} + + +def test_list_resources_returns_empty_list_when_no_resources_exist(): + logger.info("Listing resources when no resources exist") + + response = client.get("/resources") + logger.info("Empty list response status=%s body=%s", response.status_code, response.json()) + + assert response.status_code == 200 + assert response.json() == []