From 9573dc113d44892c3278c65bec88e8793b7f27a4 Mon Sep 17 00:00:00 2001 From: Osher Date: Thu, 8 Jan 2026 08:10:12 +0000 Subject: [PATCH] solved phase 1 --- .github/agents/gpt-5-beast-mode.agent.md | 109 ++++++ .vscode/settings.json | 4 + openapi.yaml | 413 ++++++++++++++++++++++ python/database.py | 28 ++ python/main.py | 430 +++++++++++++++++++++++ python/models.py | 55 +++ python/schemas.py | 58 +++ python/sns_api.db | Bin 0 -> 28672 bytes 8 files changed, 1097 insertions(+) create mode 100644 .github/agents/gpt-5-beast-mode.agent.md create mode 100644 .vscode/settings.json create mode 100644 openapi.yaml create mode 100644 python/database.py create mode 100644 python/main.py create mode 100644 python/models.py create mode 100644 python/schemas.py create mode 100644 python/sns_api.db diff --git a/.github/agents/gpt-5-beast-mode.agent.md b/.github/agents/gpt-5-beast-mode.agent.md new file mode 100644 index 0000000..8c04736 --- /dev/null +++ b/.github/agents/gpt-5-beast-mode.agent.md @@ -0,0 +1,109 @@ +--- +description: 'Beast Mode 2.0: A powerful autonomous agent tuned specifically for GPT-5 that can solve complex problems by using tools, conducting research, and iterating until the problem is fully resolved.' +model: GPT-5 (copilot) +tools: ['edit/editFiles', 'execute/runNotebookCell', 'read/getNotebookSummary', 'read/readNotebookCellOutput', 'search', 'vscode/getProjectSetupInfo', 'vscode/installExtension', 'vscode/newWorkspace', 'vscode/runCommand', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'execute/createAndRunTask', 'execute/getTaskOutput', 'execute/runTask', 'vscode/extensions', 'search/usages', 'vscode/vscodeAPI', 'think', 'read/problems', 'search/changes', 'execute/testFailure', 'vscode/openSimpleBrowser', 'web/fetch', 'web/githubRepo', 'todo'] +name: 'GPT 5 Beast Mode' +--- + +# Operating principles +- **Beast Mode = Ambitious & agentic.** Operate with maximal initiative and persistence; pursue goals aggressively until the request is fully satisfied. When facing uncertainty, choose the most reasonable assumption, act decisively, and document any assumptions after. Never yield early or defer action when further progress is possible. +- **High signal.** Short, outcome-focused updates; prefer diffs/tests over verbose explanation. +- **Safe autonomy.** Manage changes autonomously, but for wide/risky edits, prepare a brief *Destructive Action Plan (DAP)* and pause for explicit approval. +- **Conflict rule.** If guidance is duplicated or conflicts, apply this Beast Mode policy: **ambitious persistence > safety > correctness > speed**. + +## Tool preamble (before acting) +**Goal** (1 line) → **Plan** (few steps) → **Policy** (read / edit / test) → then call the tool. + +### Tool use policy (explicit & minimal) +**General** +- Default **agentic eagerness**: take initiative after **one targeted discovery pass**; only repeat discovery if validation fails or new unknowns emerge. +- Use tools **only if local context isn't enough**. Follow the mode's `tools` allowlist; file prompts may narrow/expand per task. + +**Progress (single source of truth)** +- **manage_todo_list** — establish and update the checklist; track status exclusively here. Do **not** mirror checklists elsewhere. + +**Workspace & files** +- **list_dir** to map structure → **file_search** (globs) to focus → **read_file** for precise code/config (use offsets for large files). +- **replace_string_in_file / multi_replace_string_in_file** for deterministic edits (renames/version bumps). Use semantic tools for refactoring and code changes. + +**Code investigation** +- **grep_search** (text/regex), **semantic_search** (concepts), **list_code_usages** (refactor impact). +- **get_errors** after all edits or when app behavior deviates unexpectedly. + +**Terminal & tasks** +- **run_in_terminal** for build/test/lint/CLI; **get_terminal_output** for long runs; **create_and_run_task** for recurring commands. + +**Git & diffs** +- **get_changed_files** before proposing commit/PR guidance. Ensure only intended files change. + +**Docs & web (only when needed)** +- **fetch** for HTTP requests or official docs/release notes (APIs, breaking changes, config). Prefer vendor docs; cite with title and URL. + +**VS Code & extensions** +- **vscodeAPI** (for extension workflows), **extensions** (discover/install helpers), **runCommands** for command invocations. + +**GitHub (activate then act)** +- **githubRepo** for pulling examples or templates from public or authorized repos not part of the current workspace. + +## Configuration + +Goal: gain actionable context rapidly; stop as soon as you can take effective action. +Approach: single, focused pass. Remove redundancy; avoid repetitive queries. +Early exit: once you can name the exact files/symbols/config to change, or ~70% of top hits focus on one project area. +Escalate just once: if conflicted, run one more refined pass, then proceed. +Depth: trace only symbols you'll modify or whose interfaces govern your changes. + + + +Continue working until the user request is completely resolved. Don't stall on uncertainties—make a best judgment, act, and record your rationale after. + + + +Reasoning effort: **high** by default for multi-file/refactor/ambiguous work. Lower only for trivial/latency-sensitive changes. +Verbosity: **low** for chat, **high** for code/tool outputs (diffs, patch-sets, test logs). + + + +Before every tool call, emit Goal/Plan/Policy. Tie progress updates directly to the plan; avoid narrative excess. + + + +If rules clash, apply: **safety > correctness > speed**. DAP supersedes autonomy. + + + +Leverage Markdown for clarity (lists, code blocks). Use backticks for file/dir/function/class names. Maintain brevity in chat. + + + +If output drifts (too verbose/too shallow/over-searching), self-correct the preamble with a one-line directive (e.g., "single targeted pass only") and continue—update the user only if DAP is needed. + + + +If the host supports Responses API, chain prior reasoning (`previous_response_id`) across tool calls for continuity and conciseness. + + +## Anti-patterns +- Multiple context tools when one targeted pass is enough. +- Forums/blogs when official docs are available. +- String-replace used for refactors that require semantics. +- Scaffolding frameworks already present in the repo. + +## Stop conditions (all must be satisfied) +- ✅ Full end-to-end satisfaction of acceptance criteria. +- ✅ `get_errors` yields no new diagnostics. +- ✅ All relevant tests pass (or you add/execute new minimal tests). +- ✅ Concise summary: what changed, why, test evidence, and citations. + +## Guardrails +- Prepare a **DAP** before wide renames/deletes, schema/infra changes. Include scope, rollback plan, risk, and validation plan. +- Only use the **Network** when local context is insufficient. Prefer official docs; never leak credentials or secrets. + +## Workflow (concise) +1) **Plan** — Break down the user request; enumerate files to edit. If unknown, perform a single targeted search (`search`/`usages`). Initialize **todos**. +2) **Implement** — Make small, idiomatic changes; after each edit, run **problems** and relevant tests using **runCommands**. +3) **Verify** — Rerun tests; resolve any failures; only search again if validation uncovers new questions. +4) **Research (if needed)** — Use **fetch** for docs; always cite sources. + +## Resume behavior +If prompted to *resume/continue/try again*, read the **todos**, select the next pending item, announce intent, and proceed without delay. diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..51a564d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "chat.tools.autoApprove": true, + "chat.agent.maxRequests": 100 +} \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..12ffaf7 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,413 @@ +openapi: 3.0.1 +info: + title: Simple Social Media API + version: 1.0.0 + description: | + API for a basic social networking service providing CRUD for posts, + comments on posts, and a like/unlike system. Designed per the PRD + for educational and MVP use cases. +servers: + - url: http://localhost:8080/api + description: Local development server +tags: + - name: Posts + description: Post management + - name: Comments + description: Comment management + - name: Likes + description: Like and unlike posts +paths: + /posts: + get: + tags: [Posts] + summary: List posts + description: Returns all recent posts. + operationId: listPosts + responses: + '200': + description: List of posts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + '500': + $ref: '#/components/responses/ServerError' + post: + tags: [Posts] + summary: Create post + description: Create a new post. + operationId: createPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPost' + responses: + '201': + description: Post created + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/ServerError' + /posts/{postId}: + get: + tags: [Posts] + summary: Get post + description: Retrieve a single post by ID. + operationId: getPost + parameters: + - $ref: '#/components/parameters/PostId' + responses: + '200': + description: Post retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + patch: + tags: [Posts] + summary: Update post + description: Update an existing post's content (requires username for ownership validation). + operationId: updatePost + parameters: + - $ref: '#/components/parameters/PostId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePost' + responses: + '200': + description: Post updated + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + delete: + tags: [Posts] + summary: Delete post + description: Delete a post by ID. + operationId: deletePost + parameters: + - $ref: '#/components/parameters/PostId' + responses: + '204': + $ref: '#/components/responses/NoContent' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /posts/{postId}/comments: + get: + tags: [Comments] + summary: List comments for a post + description: Returns all comments associated with the specified post. + operationId: listComments + parameters: + - $ref: '#/components/parameters/PostId' + responses: + '200': + description: List of comments + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Comment' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + post: + tags: [Comments] + summary: Create comment + description: Create a new comment on the specified post. + operationId: createComment + parameters: + - $ref: '#/components/parameters/PostId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewComment' + responses: + '201': + description: Comment created + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /posts/{postId}/comments/{commentId}: + get: + tags: [Comments] + summary: Get specific comment + description: Retrieve a single comment by ID for the specified post. + operationId: getComment + parameters: + - $ref: '#/components/parameters/PostId' + - $ref: '#/components/parameters/CommentId' + responses: + '200': + description: Comment retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + patch: + tags: [Comments] + summary: Update comment + description: Update an existing comment (requires username for ownership validation). + operationId: updateComment + parameters: + - $ref: '#/components/parameters/PostId' + - $ref: '#/components/parameters/CommentId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateComment' + responses: + '200': + description: Comment updated + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + delete: + tags: [Comments] + summary: Delete comment + description: Delete a comment by ID for the specified post. + operationId: deleteComment + parameters: + - $ref: '#/components/parameters/PostId' + - $ref: '#/components/parameters/CommentId' + responses: + '204': + $ref: '#/components/responses/NoContent' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /posts/{postId}/likes: + post: + tags: [Likes] + summary: Like a post + description: Like the specified post (requires username). + operationId: likePost + parameters: + - $ref: '#/components/parameters/PostId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LikeRequest' + responses: + '200': + description: Post liked; returns updated post + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + delete: + tags: [Likes] + summary: Unlike a post + description: Remove a like from the specified post (requires username). + operationId: unlikePost + parameters: + - $ref: '#/components/parameters/PostId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LikeRequest' + responses: + '200': + description: Post unliked; returns updated post + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' +components: + schemas: + Error: + type: object + properties: + code: + type: string + description: Machine-readable error code + example: BAD_REQUEST + message: + type: string + description: Human-readable error message + example: Invalid input for required field 'content'. + details: + type: object + additionalProperties: true + description: Optional additional error context + required: [code, message] + Identifier: + type: string + description: Resource identifier + pattern: '^[A-Za-z0-9_-]+$' + example: p_123abc + Timestamp: + type: string + format: date-time + example: '2025-05-30T12:34:56Z' + PostBase: + type: object + properties: + username: + type: string + minLength: 1 + example: alice + content: + type: string + minLength: 1 + example: "Hello Contoso community!" + required: [username, content] + Post: + allOf: + - $ref: '#/components/schemas/PostBase' + - type: object + properties: + id: + $ref: '#/components/schemas/Identifier' + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + likes: + type: integer + format: int32 + minimum: 0 + example: 3 + required: [id, createdAt, updatedAt, likes] + NewPost: + $ref: '#/components/schemas/PostBase' + UpdatePost: + $ref: '#/components/schemas/PostBase' + CommentBase: + type: object + properties: + username: + type: string + minLength: 1 + example: bob + content: + type: string + minLength: 1 + example: "Nice post!" + required: [username, content] + Comment: + allOf: + - $ref: '#/components/schemas/CommentBase' + - type: object + properties: + id: + $ref: '#/components/schemas/Identifier' + postId: + $ref: '#/components/schemas/Identifier' + createdAt: + $ref: '#/components/schemas/Timestamp' + updatedAt: + $ref: '#/components/schemas/Timestamp' + required: [id, postId, createdAt, updatedAt] + NewComment: + $ref: '#/components/schemas/CommentBase' + UpdateComment: + $ref: '#/components/schemas/CommentBase' + LikeRequest: + type: object + properties: + username: + type: string + minLength: 1 + example: alice + required: [username] + parameters: + PostId: + name: postId + in: path + description: Post identifier + required: true + schema: + $ref: '#/components/schemas/Identifier' + CommentId: + name: commentId + in: path + description: Comment identifier + required: true + schema: + $ref: '#/components/schemas/Identifier' + responses: + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + ServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NoContent: + description: No content diff --git a/python/database.py b/python/database.py new file mode 100644 index 0000000..28aa6b7 --- /dev/null +++ b/python/database.py @@ -0,0 +1,28 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_NAME = "sns_api.db" +SQLALCHEMY_DATABASE_URL = f"sqlite:///./{DATABASE_NAME}" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Initialize the database by creating all tables.""" + from models import Post, Comment, Like + Base.metadata.create_all(bind=engine) diff --git a/python/main.py b/python/main.py new file mode 100644 index 0000000..ed09815 --- /dev/null +++ b/python/main.py @@ -0,0 +1,430 @@ +from datetime import datetime +from typing import Annotated +from contextlib import asynccontextmanager +from fastapi import FastAPI, Depends, HTTPException, status, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +import yaml + +from database import get_db, init_db +from models import Post as DBPost, Comment as DBComment, Like as DBLike +from schemas import ( + Post, NewPost, UpdatePost, + Comment, NewComment, UpdateComment, + LikeRequest, ErrorResponse +) + +# Load OpenAPI specification +with open("../openapi.yaml", "r") as f: + openapi_spec = yaml.safe_load(f) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + init_db() + yield + # Shutdown (if needed) + + +# Create FastAPI app with custom OpenAPI +app = FastAPI( + title=openapi_spec["info"]["title"], + version=openapi_spec["info"]["version"], + description=openapi_spec["info"]["description"], + openapi_url="/api/openapi.json", + docs_url="/api/docs", + redoc_url="/api/redoc", + lifespan=lifespan +) + +# Configure CORS to allow all origins +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Database dependency +DBSession = Annotated[Session, Depends(get_db)] + + +# Custom OpenAPI schema to match the spec exactly +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + # Use the loaded OpenAPI spec but update paths to include /api prefix + openapi_schema = openapi_spec.copy() + + # Add the actual routes from the app + from fastapi.openapi.utils import get_openapi + generated = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + + # Use the paths from our openapi.yaml + openapi_schema["paths"] = openapi_spec["paths"] + openapi_schema["components"] = openapi_spec["components"] + openapi_schema["tags"] = openapi_spec.get("tags", []) + openapi_schema["servers"] = [{"url": "http://localhost:8000/api", "description": "Local development server"}] + openapi_schema["openapi"] = "3.0.1" + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi + + +# Helper function to create error response +def error_response(code: str, message: str, status_code: int, details: dict = None): + return JSONResponse( + status_code=status_code, + content=ErrorResponse(code=code, message=message, details=details).model_dump(exclude_none=True) + ) + + +# ==================== ROOT ENDPOINT ==================== + +@app.get("/") +def root(): + """Root endpoint - redirects to API documentation.""" + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/api/docs") + + +# ==================== POST ENDPOINTS ==================== + +@app.get("/api/posts", response_model=list[Post], tags=["Posts"]) +def list_posts(db: DBSession): + """List all posts.""" + try: + posts = db.query(DBPost).all() + return posts + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +@app.post("/api/posts", response_model=Post, status_code=status.HTTP_201_CREATED, tags=["Posts"]) +def create_post(post: NewPost, db: DBSession): + """Create a new post.""" + if not post.username or not post.content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "BAD_REQUEST", "message": "Invalid input for required fields."} + ) + + try: + db_post = DBPost( + username=post.username, + content=post.content + ) + db.add(db_post) + db.commit() + db.refresh(db_post) + return db_post + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +@app.get("/api/posts/{postId}", response_model=Post, tags=["Posts"]) +def get_post(postId: str, db: DBSession): + """Get a single post by ID.""" + post = db.query(DBPost).filter(DBPost.id == postId).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Post not found"} + ) + return post + + +@app.patch("/api/posts/{postId}", response_model=Post, tags=["Posts"]) +def update_post(postId: str, post_update: UpdatePost, db: DBSession): + """Update an existing post.""" + post = db.query(DBPost).filter(DBPost.id == postId).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Post not found"} + ) + + if not post_update.username or not post_update.content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "BAD_REQUEST", "message": "Invalid input for required fields."} + ) + + try: + post.username = post_update.username + post.content = post_update.content + post.updatedAt = datetime.utcnow() + db.commit() + db.refresh(post) + return post + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +@app.delete("/api/posts/{postId}", status_code=status.HTTP_204_NO_CONTENT, tags=["Posts"]) +def delete_post(postId: str, db: DBSession): + """Delete a post by ID.""" + post = db.query(DBPost).filter(DBPost.id == postId).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Post not found"} + ) + + try: + db.delete(post) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +# ==================== COMMENT ENDPOINTS ==================== + +@app.get("/api/posts/{postId}/comments", response_model=list[Comment], tags=["Comments"]) +def list_comments(postId: str, db: DBSession): + """List all comments for a post.""" + post = db.query(DBPost).filter(DBPost.id == postId).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Post not found"} + ) + + try: + comments = db.query(DBComment).filter(DBComment.postId == postId).all() + return comments + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +@app.post("/api/posts/{postId}/comments", response_model=Comment, status_code=status.HTTP_201_CREATED, tags=["Comments"]) +def create_comment(postId: str, comment: NewComment, db: DBSession): + """Create a new comment on a post.""" + post = db.query(DBPost).filter(DBPost.id == postId).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Post not found"} + ) + + if not comment.username or not comment.content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "BAD_REQUEST", "message": "Invalid input for required fields."} + ) + + try: + db_comment = DBComment( + postId=postId, + username=comment.username, + content=comment.content + ) + db.add(db_comment) + db.commit() + db.refresh(db_comment) + return db_comment + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +@app.get("/api/posts/{postId}/comments/{commentId}", response_model=Comment, tags=["Comments"]) +def get_comment(postId: str, commentId: str, db: DBSession): + """Get a specific comment.""" + comment = db.query(DBComment).filter( + DBComment.id == commentId, + DBComment.postId == postId + ).first() + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Comment not found"} + ) + return comment + + +@app.patch("/api/posts/{postId}/comments/{commentId}", response_model=Comment, tags=["Comments"]) +def update_comment(postId: str, commentId: str, comment_update: UpdateComment, db: DBSession): + """Update an existing comment.""" + comment = db.query(DBComment).filter( + DBComment.id == commentId, + DBComment.postId == postId + ).first() + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Comment not found"} + ) + + if not comment_update.username or not comment_update.content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "BAD_REQUEST", "message": "Invalid input for required fields."} + ) + + try: + comment.username = comment_update.username + comment.content = comment_update.content + comment.updatedAt = datetime.utcnow() + db.commit() + db.refresh(comment) + return comment + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +@app.delete("/api/posts/{postId}/comments/{commentId}", status_code=status.HTTP_204_NO_CONTENT, tags=["Comments"]) +def delete_comment(postId: str, commentId: str, db: DBSession): + """Delete a comment.""" + comment = db.query(DBComment).filter( + DBComment.id == commentId, + DBComment.postId == postId + ).first() + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Comment not found"} + ) + + try: + db.delete(comment) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +# ==================== LIKE ENDPOINTS ==================== + +@app.post("/api/posts/{postId}/likes", response_model=Post, tags=["Likes"]) +def like_post(postId: str, like_request: LikeRequest, db: DBSession): + """Like a post.""" + post = db.query(DBPost).filter(DBPost.id == postId).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Post not found"} + ) + + if not like_request.username: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "BAD_REQUEST", "message": "Invalid input for required field 'username'."} + ) + + try: + # Check if already liked + existing_like = db.query(DBLike).filter( + DBLike.postId == postId, + DBLike.username == like_request.username + ).first() + + if not existing_like: + # Create new like + db_like = DBLike( + postId=postId, + username=like_request.username + ) + db.add(db_like) + post.likes += 1 + db.commit() + db.refresh(post) + + return post + except IntegrityError: + db.rollback() + # Already liked, just return the post + db.refresh(post) + return post + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +@app.delete("/api/posts/{postId}/likes", response_model=Post, tags=["Likes"]) +def unlike_post(postId: str, like_request: LikeRequest, db: DBSession): + """Unlike a post.""" + post = db.query(DBPost).filter(DBPost.id == postId).first() + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Post not found"} + ) + + if not like_request.username: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "BAD_REQUEST", "message": "Invalid input for required field 'username'."} + ) + + try: + # Find and delete the like + like = db.query(DBLike).filter( + DBLike.postId == postId, + DBLike.username == like_request.username + ).first() + + if like: + db.delete(like) + post.likes = max(0, post.likes - 1) + db.commit() + db.refresh(post) + + return post + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "SERVER_ERROR", "message": str(e)} + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/python/models.py b/python/models.py new file mode 100644 index 0000000..066ca74 --- /dev/null +++ b/python/models.py @@ -0,0 +1,55 @@ +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship +from database import Base +import secrets +import string + + +def generate_id(prefix: str) -> str: + """Generate a random ID with the given prefix.""" + chars = string.ascii_letters + string.digits + "_-" + random_part = ''.join(secrets.choice(chars) for _ in range(8)) + return f"{prefix}_{random_part}" + + +class Post(Base): + __tablename__ = "posts" + + id = Column(String, primary_key=True, default=lambda: generate_id("p")) + username = Column(String, nullable=False) + content = Column(String, nullable=False) + createdAt = Column(DateTime, nullable=False, default=datetime.utcnow) + updatedAt = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + likes = Column(Integer, nullable=False, default=0) + + comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan") + liked_by = relationship("Like", back_populates="post", cascade="all, delete-orphan") + + +class Comment(Base): + __tablename__ = "comments" + + id = Column(String, primary_key=True, default=lambda: generate_id("c")) + postId = Column(String, ForeignKey("posts.id"), nullable=False) + username = Column(String, nullable=False) + content = Column(String, nullable=False) + createdAt = Column(DateTime, nullable=False, default=datetime.utcnow) + updatedAt = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + post = relationship("Post", back_populates="comments") + + +class Like(Base): + __tablename__ = "likes" + + id = Column(Integer, primary_key=True, autoincrement=True) + postId = Column(String, ForeignKey("posts.id"), nullable=False) + username = Column(String, nullable=False) + createdAt = Column(DateTime, nullable=False, default=datetime.utcnow) + + post = relationship("Post", back_populates="liked_by") + + __table_args__ = ( + UniqueConstraint('postId', 'username', name='unique_post_user_like'), + ) diff --git a/python/schemas.py b/python/schemas.py new file mode 100644 index 0000000..134b03d --- /dev/null +++ b/python/schemas.py @@ -0,0 +1,58 @@ +from datetime import datetime +from pydantic import BaseModel, Field + + +class ErrorResponse(BaseModel): + code: str = Field(..., description="Machine-readable error code") + message: str = Field(..., description="Human-readable error message") + details: dict | None = Field(None, description="Optional additional error context") + + +class PostBase(BaseModel): + username: str = Field(..., min_length=1, examples=["alice"]) + content: str = Field(..., min_length=1, examples=["Hello Contoso community!"]) + + +class NewPost(PostBase): + pass + + +class UpdatePost(PostBase): + pass + + +class Post(PostBase): + id: str = Field(..., pattern=r"^[A-Za-z0-9_-]+$", examples=["p_123abc"]) + createdAt: datetime = Field(..., examples=["2025-05-30T12:34:56Z"]) + updatedAt: datetime = Field(..., examples=["2025-05-30T12:34:56Z"]) + likes: int = Field(..., ge=0, examples=[3]) + + class Config: + from_attributes = True + + +class CommentBase(BaseModel): + username: str = Field(..., min_length=1, examples=["bob"]) + content: str = Field(..., min_length=1, examples=["Nice post!"]) + + +class NewComment(CommentBase): + pass + + +class UpdateComment(CommentBase): + pass + + +class Comment(CommentBase): + id: str = Field(..., pattern=r"^[A-Za-z0-9_-]+$", examples=["c_123abc"]) + postId: str = Field(..., pattern=r"^[A-Za-z0-9_-]+$", examples=["p_123abc"]) + createdAt: datetime = Field(..., examples=["2025-05-30T12:34:56Z"]) + updatedAt: datetime = Field(..., examples=["2025-05-30T12:34:56Z"]) + + class Config: + from_attributes = True + + +class LikeRequest(BaseModel): + username: str = Field(..., min_length=1, examples=["alice"]) diff --git a/python/sns_api.db b/python/sns_api.db new file mode 100644 index 0000000000000000000000000000000000000000..8a7c936f66473af71badcb59e272b591ed55cb8f GIT binary patch literal 28672 zcmeI(Pfyce90%~HFg95jGcl$ILVUJ^VF}ExY>bhZSZ9I7(McIbj7igNRU5Vy){b}; z_2Ap!;L&#?Z^1i9Pab{xY!wDWLOk#%-=_c8r|t7RpFUmE=htqmmu=6YZ=Fud^yoaf zO9X*Dp_C93W>bVs!7$2JLctHV6i%$4w;Cqty|1zOZxWu!lgXd)AF(f!-zW01*XJF8 z?GS(f1Rwwb2tWV=5P-n76WFI?5m6L{&ksDa*0h@Tw&n6!xTvd2Ri#zsX<4Ow6P<~U z+YMUMs_KfWQ>{{^+Ge>tOQYk-9mn-bjU;`k=*3lKFtO`eownJsh9)NK9n18rhTti$Aj;t!PzYBV&l?zBCY z_0Y5n$&=lk#-*>xrA!}%!eVjyJkvnN$OLO+AGZbGPCw;%JLT7h&qfAJ``3-P(_x@j zXx6)hhTS};TR3ZSBmZi0ypl$SFZ^fuJN~~)j7>uP8(|{`2tWV=5P$##AOHafKmY;| zfB*!pRN$2mNr)*$8Q(EXMekPL6<5rrUAI=PX49cX)@kFoPC}OCg*hoRCoNJbzm&}^ zWeVwRMwaFmPo>C9QZ}8-XXTt6W%Ux`zX%&KKmY;|fB*y_009U<00Izz00ba#YXoAW zi4)xjz4-qDi67jW3ZfzqfB*y_009U<00Izz00bZafg33BhQMfvB`vMeuTb7$lK{geXY|2MEnpvVw_00bZa0SG_<0uX=z z1Rwx`3kCf6|8ZABFaCc>;)fR=h#epR0SG_<0uX=z1Rwwb2tWV=5V#fsk}yURQ_AuC z0rmH0r)gVfy_|dhf015#C2tWV=5P$##AOHafKmY;|fWUtV+z%zD LPJH;^?-}?5NK|yZ literal 0 HcmV?d00001