Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<id>` | 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)
Expand Down
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
@@ -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/<id>", methods=["GET"])
def get_snippet(id):
"""
GET /api/snippets/<id>
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
8 changes: 6 additions & 2 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
97 changes: 97 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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/<id> 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/<id> 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/<id> 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