diff --git a/README.md b/README.md index 8b82aee..a377f8c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Every developer accumulates dozens of useful code snippets. A regex that finally - Save snippets with title, language, description and tags - Full-text search across title and description - Filter by language or tag +- REST API for accessing and searching snippets programmatically - Pagination, so the grid stays fast as your stash grows - Syntax highlighting via highlight.js - Copy to clipboard in one click, from the snippet detail page or straight from the card grid @@ -188,6 +189,20 @@ Thanks to everyone who has contributed to StashSnip --- +## REST API + +StashSnip exposes a lightweight, public REST API mounted under `/api`: + +| Method | Endpoint | Description | Query Parameters | +|---|---|---|---| +| `GET` | `/api/snippets` | List all snippets in JSON format | `?q=search_term`, `?language=py`, `?tag=flask` | +| `GET` | `/api/snippets/` | Get a single snippet document by ID | None | + +### Authentication & Scope +The API is currently open (unauthenticated) for client tools and browser extensions. + +--- + ## License MIT — see [LICENSE](LICENSE) diff --git a/app/__init__.py b/app/__init__.py index c8c9b0f..f6a5a9a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,6 +8,9 @@ def create_app(): from app.routes import main app.register_blueprint(main) + from app.api import api + app.register_blueprint(api, url_prefix="/api") + @app.errorhandler(404) def page_not_found(e): return render_template("404.html"), 404 diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..760fe1c --- /dev/null +++ b/app/api.py @@ -0,0 +1,55 @@ +import re +from bson import ObjectId +from bson.errors import InvalidId +from flask import Blueprint, jsonify, request + +from app.db import snippets_collection +from app.models import format_snippet + +api = Blueprint("api", __name__) + + +@api.route("/snippets", methods=["GET"]) +def get_snippets(): + """ + GET /api/snippets + Optional query parameters: ?q=search_term&language=py&tag=flask + Returns a list of snippet documents in JSON format. + """ + query = request.args.get("q", "").strip() + tag = request.args.get("tag", "").strip() + language = request.args.get("language", "").strip() + + filters = {} + if query: + escaped_query = re.escape(query) + filters["$or"] = [ + {"title": {"$regex": escaped_query, "$options": "i"}}, + {"description": {"$regex": escaped_query, "$options": "i"}}, + ] + if tag: + filters["tags"] = tag + if language: + filters["language"] = language + + snippets = list(snippets_collection.find(filters).sort("created_at", -1)) + formatted_snippets = [format_snippet(dict(s)) for s in snippets] + return jsonify(formatted_snippets), 200 + + +@api.route("/snippets/", methods=["GET"]) +def get_snippet(id): + """ + GET /api/snippets/ + Returns a single snippet document by ID or 404 if not found. + """ + try: + obj_id = ObjectId(id) + except (InvalidId, TypeError): + return jsonify({"error": "Snippet not found"}), 404 + + snippet = snippets_collection.find_one({"_id": obj_id}) + if not snippet: + return jsonify({"error": "Snippet not found"}), 404 + + return jsonify(format_snippet(dict(snippet))), 200 diff --git a/app/models.py b/app/models.py index 8e6efba..b8a1583 100644 --- a/app/models.py +++ b/app/models.py @@ -19,8 +19,12 @@ def make_snippet(title, language, code, description, tags): def format_snippet(snippet): """ - Converts ObjectId to string so we can safely pass - snippets around without MongoDB-specific types. + Converts ObjectId and datetime objects so we can safely pass + snippets around or return them as JSON without serialization errors. """ snippet["_id"] = str(snippet["_id"]) + if isinstance(snippet.get("created_at"), datetime): + snippet["created_at"] = snippet["created_at"].isoformat() + if isinstance(snippet.get("updated_at"), datetime): + snippet["updated_at"] = snippet["updated_at"].isoformat() return snippet \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..6720878 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,97 @@ +import os +import sys +import pytest +from bson import ObjectId + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017/stashsnip_test") + +from app import create_app +from tests.test_routes import FakeSnippetsCollection, make_test_snippet + + +@pytest.fixture +def fake_collection(monkeypatch): + import app.api as api_module + import app.routes as routes_module + + collection = FakeSnippetsCollection() + monkeypatch.setattr(api_module, "snippets_collection", collection) + monkeypatch.setattr(routes_module, "snippets_collection", collection) + return collection + + +@pytest.fixture +def client(fake_collection): + app = create_app() + app.config["TESTING"] = True + app.config["WTF_CSRF_ENABLED"] = False + with app.test_client() as client: + yield client + + +def test_get_snippets_empty(client, fake_collection): + """GET /api/snippets should return an empty JSON array when no snippets exist""" + fake_collection.documents = [] + response = client.get("/api/snippets") + assert response.status_code == 200 + json_data = response.get_json() + assert isinstance(json_data, list) + assert len(json_data) == 0 + + +def test_get_snippets_populated(client, fake_collection): + """GET /api/snippets should return all snippets in JSON format""" + fake_collection.documents = [ + make_test_snippet(1, language="python", tags=["flask"]), + make_test_snippet(2, language="javascript", tags=["node"]), + ] + response = client.get("/api/snippets") + assert response.status_code == 200 + json_data = response.get_json() + assert len(json_data) == 2 + assert json_data[0]["language"] == "javascript" + assert json_data[1]["language"] == "python" + + +def test_get_snippets_with_query_filters(client, fake_collection): + """GET /api/snippets?language=python should return filtered snippets""" + fake_collection.documents = [ + make_test_snippet(1, language="python", tags=["flask"]), + make_test_snippet(2, language="javascript", tags=["node"]), + ] + response = client.get("/api/snippets?language=python") + assert response.status_code == 200 + json_data = response.get_json() + assert len(json_data) == 1 + assert json_data[0]["language"] == "python" + + +def test_get_snippet_by_id_success(client, fake_collection): + """GET /api/snippets/ should return a single snippet document""" + snippet = make_test_snippet(1, language="python") + fake_collection.documents = [snippet] + snippet_id = str(snippet["_id"]) + + response = client.get(f"/api/snippets/{snippet_id}") + assert response.status_code == 200 + json_data = response.get_json() + assert json_data["_id"] == snippet_id + assert json_data["title"] == snippet["title"] + + +def test_get_snippet_by_id_not_found(client, fake_collection): + """GET /api/snippets/ should return 404 for non-existent ID""" + fake_collection.documents = [] + response = client.get("/api/snippets/000000000000000000000000") + assert response.status_code == 404 + json_data = response.get_json() + assert "error" in json_data + + +def test_get_snippet_invalid_id_format(client): + """GET /api/snippets/ should return 404 for invalid ObjectId format""" + response = client.get("/api/snippets/invalid-id-string") + assert response.status_code == 404 + json_data = response.get_json() + assert "error" in json_data