Skip to content

Commit f6b9f69

Browse files
authored
feat(app): add simple rest api (#1463)
- Update `web` dependency group to be smaller and FastAPI oriented - Add `web` dependency group to the `python-env` in the Nix flake - Get rid of Redis worker
1 parent 5b3d0bb commit f6b9f69

File tree

6 files changed

+965
-788
lines changed

6 files changed

+965
-788
lines changed

app.py

Lines changed: 101 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,101 @@
1-
"""Web app that serves proselint's API."""
2-
3-
from flask import Flask, request, jsonify, make_response, Response
4-
from flask_cors import CORS, cross_origin
5-
from flask_limiter import Limiter
6-
from functools import wraps
7-
from urllib.parse import unquote
8-
import hashlib
9-
import proselint
10-
from rq import Queue
11-
from worker import conn
12-
13-
14-
app = Flask(__name__)
15-
cors = CORS(app)
16-
app.config['CORS_HEADERS'] = "Origin, X-Requested-With, Content-Type, Accept"
17-
limiter = Limiter(app)
18-
19-
q = Queue(connection=conn)
20-
21-
22-
def worker_function(text):
23-
"""Lint the text using a worker dyno."""
24-
return proselint.tools.lint(text)
25-
26-
27-
@app.errorhandler(429)
28-
def ratelimit_handler(e):
29-
"""Inform user that the rate limit has been exceeded."""
30-
return make_response(
31-
jsonify(status="error", message="Rate limit exceeded."), 429)
32-
33-
34-
def check_auth(username, password):
35-
"""Check if a username / password combination is valid."""
36-
legal_hashes = [
37-
"15a7fdade5fa58d38c6d400770e5c0e948fbc03ba365b704a6d205687738ae46",
38-
"057b24043181523e3c3717071953c575bd13862517a8ce228601582a9cbd9dae",
39-
"c8d79ae7d388b6da21cb982a819065b18941925179f88041b87de2be9bcde79c",
40-
"bb7082271060c91122de8a3bbac5c7d6dcfe1a02902d57b27422f0062f602f72",
41-
"90e4d9e4ec680ff40ce00e712b24a67659539e06d50b538ed4a3d960b4f3bda5",
42-
"9a9a241b05eeaa0ca2b4323c5a756851c9cd15371a4d71a326749abc47062bf0",
43-
"0643786903dab7cbb079796ea4b27a81fb38442381773759dd52ac8615eb6ab2",
44-
"886078097635635c1450cf52ca0ec13a887ea4f8cd4b799fdedc650ec1f08781",
45-
"d4c4d2d16c7fec2d0d60f0a110eb4fbd9f1bb463033298e01b3141a7e4ca10bc",
46-
"83dfe687131d285a22c5379e19f4ebabcdfe8a19bade46a5cdcdc9e9c36b52a2",
47-
"7c4000e5d6948055553eb84fc2188ccad068caa1b584303f88dc0c582a0ecd42",
48-
"43c693fa32545b7d4106e337fe6edf7db92282795d5bdb80705ef8a0ac7e8030",
49-
"ebb17f7f9050e3c1b18f84cbd6333178d575d4baf3aca6dfa0587cc2a48e02d0",
50-
"ce910c4368092bf0886e59dc5df0b0ad11f40067b685505c2195463d32fa0418",
51-
"86fc704debb389a73775e02f8f0423ffbbb787a1033e531b2e47d40f71ad5560",
52-
"308af1914cb90aeb8913548cc37c9b55320875a2c0d2ecfe6afe1bfc02c64326",
53-
"bd3486100f2bb29762100b93b1f1cd41655ab05767f78fb1fc4adfe040ebe953",
54-
"29f56ee67dd218276984d723b6b105678faa1868a9644f0d9c49109c8322e1d8",
55-
"704c3ddde0b5fd3c6971a6ef16991ddff3e241c170ed539094ee668861e01764",
56-
"aaebc3ca0fe041a3a595170b8efda22308cd7d843510bf01263f05a1851cb173",
57-
]
58-
return hashlib.sha256(username + password).hexdigest() in legal_hashes
59-
60-
61-
def authenticate():
62-
"""Send a 401 response that enables basic auth."""
63-
return Response(
64-
'Could not verify your access level for that URL.\n'
65-
'You have to login with proper credentials', 401,
66-
{'WWW-Authenticate': 'Basic realm="Login Required"'})
67-
68-
69-
def requires_auth(f):
70-
"""Decorate methods that require authentication."""
71-
@wraps(f)
72-
def decorated(*args, **kwargs):
73-
auth = request.authorization
74-
if not auth or not check_auth(auth.username, auth.password):
75-
return authenticate()
76-
return f(*args, **kwargs)
77-
return decorated
78-
79-
80-
def rate():
81-
"""Set rate limits for authenticated and nonauthenticated users."""
82-
auth = request.authorization
83-
84-
if not auth or not check_auth(auth.username, auth.password):
85-
return "60/minute"
86-
else:
87-
return "600/minute"
88-
89-
90-
@app.route('/v0/', methods=['GET', 'POST'])
91-
@limiter.limit(rate)
92-
@cross_origin() # allow all origins all methods.
93-
def lint():
94-
"""Run linter on the provided text and return the results."""
95-
if 'text' in request.values:
96-
text = unquote(request.values['text'])
97-
job = q.enqueue(worker_function, text)
98-
99-
return jsonify(job_id=job.id), 202
100-
101-
elif 'job_id' in request.values:
102-
job = q.fetch_job(request.values['job_id'])
103-
104-
if not job:
105-
return jsonify(
106-
status="error",
107-
message="No job with requested job_id."), 404
108-
109-
elif job.result is None:
110-
return jsonify(
111-
status="error",
112-
message="Job is not yet ready."), 202
113-
114-
else:
115-
errors = []
116-
for i, e in enumerate(job.result):
117-
app.logger.debug(e)
118-
errors.append({
119-
"check": e[0],
120-
"message": e[1],
121-
"line": e[2],
122-
"column": e[3],
123-
"start": e[4],
124-
"end": e[5],
125-
"extent": e[5] - e[4],
126-
"severity": e[7],
127-
"replacements": e[8],
128-
"source_name": "",
129-
"source_url": "",
130-
})
131-
return jsonify(
132-
status="success",
133-
data={"errors": errors})
134-
135-
136-
if __name__ == '__main__':
137-
app.debug = True
138-
app.run()
1+
"""A simple FastAPI app that serves a REST API for proselint."""
2+
3+
from os import getenv
4+
5+
from fastapi import FastAPI, HTTPException, Request, Response, status
6+
from fastapi.middleware.cors import CORSMiddleware
7+
from slowapi import Limiter
8+
from slowapi.errors import RateLimitExceeded
9+
from slowapi.util import get_remote_address
10+
from starlette.responses import JSONResponse
11+
12+
from proselint.checks import __register__
13+
from proselint.registry import CheckRegistry
14+
from proselint.tools import LintFile, LintResult
15+
16+
MAX_BODY_BYTES = int(getenv("MAX_BODY_BYTES", str(64 * 1024)))
17+
RATELIMIT = getenv("RATELIMIT", "60/minute")
18+
19+
20+
def _lint(input_text: str) -> list[LintResult]:
21+
return LintFile(content=input_text, source="<api>").lint()
22+
23+
24+
def _error(
25+
status: int,
26+
message: str,
27+
) -> HTTPException:
28+
return HTTPException(
29+
status_code=status,
30+
detail={
31+
"status": "error",
32+
"message": message,
33+
},
34+
)
35+
36+
37+
app = FastAPI()
38+
limiter = Limiter(key_func=get_remote_address)
39+
40+
app.state.limiter = limiter
41+
app.add_middleware(
42+
CORSMiddleware,
43+
allow_origins=["*"],
44+
allow_methods=["*"],
45+
allow_headers=["*"],
46+
)
47+
48+
CheckRegistry().register_many(__register__)
49+
50+
51+
@app.exception_handler(RateLimitExceeded)
52+
def rate_limit_exceeded_handler(
53+
_: Request,
54+
exc: RateLimitExceeded,
55+
) -> Response:
56+
"""Middleware to handle exceeded ratelimits."""
57+
return JSONResponse(
58+
{
59+
"status": "error",
60+
"message": "rate limit exceeded",
61+
"limit": str(exc.detail),
62+
},
63+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
64+
headers=getattr(exc, "headers", None),
65+
)
66+
67+
68+
@app.get("/v1/health")
69+
async def health() -> dict[str, str]:
70+
"""Endpoint to check if the service is alive."""
71+
return {"status": "success", "message": "service is healthy"}
72+
73+
74+
@app.post("/v1")
75+
@limiter.limit(RATELIMIT)
76+
async def index(request: Request) -> dict[str, object]:
77+
"""Endpoint that lints text using proselint."""
78+
body = await request.body()
79+
80+
if not body:
81+
raise _error(
82+
status.HTTP_400_BAD_REQUEST, "request body must contain text"
83+
)
84+
85+
if len(body) > MAX_BODY_BYTES:
86+
raise _error(
87+
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
88+
f"request body must be at most {MAX_BODY_BYTES} bytes",
89+
)
90+
91+
try:
92+
text = body.decode("utf-8")
93+
except UnicodeDecodeError:
94+
raise _error(
95+
status.HTTP_400_BAD_REQUEST, "request body must be valid utf-8 text"
96+
) from None
97+
98+
return {
99+
"status": "success",
100+
"data": [r.into_dict() for r in _lint(text)],
101+
}

flake.nix

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
]
119119
);
120120

121-
virtualenv = editablePythonSet.mkVirtualEnv "proselint-env" {proselint = ["test" "dev"];};
121+
virtualenv = editablePythonSet.mkVirtualEnv "proselint-env" {proselint = ["test" "dev" "web"];};
122122
in
123123
pkgs.mkShell {
124124
buildInputs = check.enabledPackages;
@@ -147,7 +147,11 @@
147147
});
148148

149149
packages =
150-
forAllSystems ({pythonSet, ...}: {
150+
forAllSystems ({
151+
pythonSet,
152+
pkgs,
153+
...
154+
}: {
151155
default = pythonSet.mkVirtualEnv "proselint-env" workspace.deps.default;
152156

153157
wheel =
@@ -161,6 +165,34 @@
161165
}).overrideAttrs (old: {
162166
env.uvBuildType = "sdist";
163167
});
168+
169+
api = let
170+
env =
171+
pythonSet.mkVirtualEnv "proselint-api-env" {
172+
proselint = ["web"];
173+
};
174+
in
175+
pkgs.stdenv.mkDerivation {
176+
name = "proselint-api";
177+
src = ./.;
178+
179+
dontBuild = true;
180+
dontConfigure = true;
181+
182+
installPhase = ''
183+
mkdir -p $out/bin $out/share/proselint-api
184+
185+
cp $src/app.py $out/share/proselint-api
186+
187+
cat > $out/bin/proselint-api-run <<-EOF
188+
#!${pkgs.bash}/bin/bash
189+
cd $out/share/proselint-api
190+
exec ${env}/bin/uvicorn app:app "\$@"
191+
EOF
192+
193+
chmod +x $out/bin/proselint-api-run
194+
'';
195+
};
164196
});
165197

166198
apps =
@@ -169,6 +201,11 @@
169201
type = "app";
170202
program = "${self.packages.${system}.default}/bin/proselint";
171203
};
204+
205+
api = {
206+
type = "app";
207+
program = "${self.packages.${system}.api}/bin/proselint-api-run";
208+
};
172209
});
173210

174211
checks =

pyproject.toml

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "proselint"
33
description = "A linter for prose."
44
version = "0.16.0"
55
license = { file = "LICENSE.md" }
6-
authors = [{ name = "Amperser Labs", email = "hello@amperser.com"}]
6+
authors = [{ name = "Amperser Labs", email = "hello@amperser.com" }]
77
readme = "README.md"
88
classifiers = [
99
"Programming Language :: Python :: Implementation :: CPython",
@@ -15,10 +15,7 @@ classifiers = [
1515
"Programming Language :: Python :: 3.14",
1616
]
1717
requires-python = ">=3.10"
18-
dependencies = [
19-
"google-re2>=1.1.20251105",
20-
"google-re2-stubs>=0.1.1",
21-
]
18+
dependencies = ["google-re2>=1.1.20251105", "google-re2-stubs>=0.1.1"]
2219

2320
[project.urls]
2421
Homepage = "https://github.com/amperser/proselint"
@@ -45,22 +42,8 @@ test = [
4542
"pytest-cov>=6.2.1",
4643
"rstr>=3.2.2",
4744
]
48-
dev = [
49-
"ruff>=0.1.14",
50-
"poethepoet>=0.34.0",
51-
]
52-
web = [
53-
"APScheduler>=3.5.3",
54-
"Flask-API>=1.0",
55-
"Flask-Cors>=3.0.4",
56-
"Flask>=1.1.4",
57-
"Flask-Limiter>=1.0.1",
58-
"gunicorn>=19.8.1",
59-
"gmail @ git+https://github.com/charlierguo/gmail.git",
60-
"redis>=2.10.6",
61-
"requests>=2.19.1",
62-
"rq>=0.12.0",
63-
]
45+
dev = ["ruff>=0.1.14", "poethepoet>=0.34.0"]
46+
web = ["slowapi>=0.1.9", "fastapi>=0.124.4", "uvicorn[standard]>=0.38.0"]
6447

6548
[tool.pdm.build]
6649
includes = ["proselint/"]
@@ -109,14 +92,16 @@ select = [
10992
"YTT", # flake8-2020
11093
]
11194
ignore = [
112-
"COM812", # trailing comma -> done by formatter
113-
"D203", # no blank lines between classes and documentation
114-
"D212", # consistent start level for multi-line edocumentation
115-
"D415", # first line of docs should end with a period
95+
"COM812", # trailing comma -> done by formatter
96+
"D203", # no blank lines between classes and documentation
97+
"D212", # consistent start level for multi-line edocumentation
98+
"D415", # first line of docs should end with a period
11699
]
117100
preview = true
118101

119102
[tool.ruff.lint.per-file-ignores]
120103
"tests/**" = ["S101", "SLF001", "PLC2701"]
121104
"proselint/checks/**" = ["RUF001"]
122-
"proselint/registry/checks/engine.py" = ["D102"] # docs are inherited from base classes
105+
"proselint/registry/checks/engine.py" = [
106+
"D102",
107+
] # docs are inherited from base classes

typings/slowapi/__init__.pyi

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Any, Callable, TypeVar
2+
from starlette.requests import Request
3+
4+
F = TypeVar("F", bound=Callable[..., Any])
5+
KeyFunc = Callable[[Request], str]
6+
7+
class Limiter:
8+
def __init__(self, key_func: KeyFunc) -> None: ...
9+
10+
def limit(self, limit: str) -> Callable[[F], F]: ...

0 commit comments

Comments
 (0)