From bd6e2f503bba022a410bd9138f032044247f519b Mon Sep 17 00:00:00 2001 From: Abdulhaleem Nasredeen Date: Tue, 4 Mar 2025 15:52:25 +0100 Subject: [PATCH] Feat: Refactored api.py into routes. Fixes #232 --- folksonomy/api.py | 551 +--------------------------------- folksonomy/routes/__init__.py | 0 folksonomy/routes/auth.py | 130 ++++++++ folksonomy/routes/product.py | 261 ++++++++++++++++ folksonomy/routes/stats.py | 129 ++++++++ folksonomy/utils/auth.py | 50 +++ folksonomy/utils/query.py | 21 ++ 7 files changed, 599 insertions(+), 543 deletions(-) create mode 100644 folksonomy/routes/__init__.py create mode 100644 folksonomy/routes/auth.py create mode 100644 folksonomy/routes/product.py create mode 100644 folksonomy/routes/stats.py create mode 100644 folksonomy/utils/auth.py create mode 100644 folksonomy/utils/query.py diff --git a/folksonomy/api.py b/folksonomy/api.py index 843fe7f8..e672781c 100644 --- a/folksonomy/api.py +++ b/folksonomy/api.py @@ -4,9 +4,10 @@ import os import logging from logging.handlers import RotatingFileHandler + +from folksonomy.routes import auth, product, stats from .dependencies import * from . import db -from . import settings from fastapi.middleware.cors import CORSMiddleware @@ -80,546 +81,10 @@ async def hello(): return {"message": "Hello folksonomy World! Tip: open /docs for documentation"} -async def get_current_user(token: str = Depends(oauth2_scheme)): - """ - Get current user and check token validity if present - """ - if token and '__U' in token: - cur = db.cursor() - await cur.execute( - "UPDATE auth SET last_use = current_timestamp AT TIME ZONE 'GMT' WHERE token = %s", (token,) - ) - if cur.rowcount == 1: - return User(user_id=token.split('__U', 1)[0]) - else: - return User(user_id=None) - - -def sanitize_data(k, v): - """Some sanitization of data""" - k = k.strip() - v = v.strip() if v else v - return k, v - - -def check_owner_user(user: User, owner, allow_anonymous=False): - """ - Check authentication depending on current user and 'owner' of the data - """ - user = user.user_id if user is not None else None - if user is None and allow_anonymous == False: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required", - headers={"WWW-Authenticate": "Bearer"}, - ) - if owner != '': - if user is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required for '%s'" % owner, - headers={"WWW-Authenticate": "Bearer"}, - ) - if owner != user: - raise HTTPException( - status_code=422, - detail="owner should be '%s' or '' for public, but '%s' is authenticated" % ( - owner, user), - ) - return - - -def get_auth_server(request: Request): - """ - Get auth server URL from request - - We deduce it by changing part of the request base URL - according to FOLKSONOMY_PREFIX and AUTH_PREFIX settings - """ - # For dev purposes, we can use a static auth server with AUTH_SERVER_STATIC - # which can be specified in local_settings.py - if hasattr(settings, 'AUTH_SERVER_STATIC') and settings.AUTH_SERVER_STATIC: - return settings.AUTH_SERVER_STATIC - base_url = f"{request.base_url.scheme}://{request.base_url.netloc}" - # remove folksonomy prefix and add AUTH prefix - base_url = base_url.replace(settings.FOLKSONOMY_PREFIX or "", settings.AUTH_PREFIX or "") - return base_url - - -@app.post("/auth") -async def authentication(request: Request, response: Response, form_data: OAuth2PasswordRequestForm = Depends()): - """ - Authentication: provide user/password and get a bearer token in return - - - **username**: Open Food Facts user_id (not email) - - **password**: user password (clear text, but HTTPS encrypted) - - token is returned, to be used in later requests with usual "Authorization: bearer token" headers - """ - - user_id = form_data.username - password = form_data.password - token = user_id+'__U'+str(uuid.uuid4()) - auth_url = get_auth_server(request) + "/cgi/auth.pl" - auth_data={'user_id': user_id, 'password': password} - async with aiohttp.ClientSession() as http_session: - async with http_session.post(auth_url, data=auth_data) as resp: - status_code = resp.status - if status_code == 200: - cur, timing = await db.db_exec(""" - DELETE FROM auth WHERE user_id = %s; - INSERT INTO auth (user_id, token, last_use) VALUES (%s,%s,current_timestamp AT TIME ZONE 'GMT'); - """, (user_id, user_id, token)) - if cur.rowcount == 1: - return {"access_token": token, "token_type": "bearer"} - elif status_code == 403: - await asyncio.sleep(settings.FAILED_AUTH_WAIT_TIME) # prevents brute-force - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={ - "WWW-Authenticate": "Bearer", - "x-auth-url": auth_url - }, - ) - elif status_code == 404: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid auth server: 404", - headers={ - "WWW-Authenticate": "Bearer", - "x-auth-url": auth_url - }, - ) - raise HTTPException( - status_code=500, detail="Server error") - - -@app.post("/auth_by_cookie") -async def authentication(request: Request, response: Response, session: Optional[str] = Cookie(None)): - """ - Authentication: provide Open Food Facts session cookie and get a bearer token in return - - - **session cookie**: Open Food Facts session cookie - - token is returned, to be used in later requests with usual "Authorization: bearer token" headers - """ - if not session or session =='': - raise HTTPException( - status_code=422, detail="Missing 'session' cookie") - - try: - session_data = session.split('&') - user_id = session_data[session_data.index('user_id') + 1] - token = user_id + '__U' + str(uuid.uuid4()) - except: - raise HTTPException( - status_code=422, detail="Malformed 'session' cookie") - - auth_url = get_auth_server(request) + "/cgi/auth.pl" - async with aiohttp.ClientSession() as http_session: - async with http_session.post(auth_url, cookies={'session': session}) as resp: - status_code = resp.status - - if status_code == 200: - cur, timing = await db.db_exec( - """ - DELETE FROM auth WHERE user_id = %s; - INSERT INTO auth (user_id, token, last_use) VALUES (%s,%s,current_timestamp AT TIME ZONE 'GMT'); - """, - (user_id, user_id, token) - ) - if cur.rowcount == 1: - return {"access_token": token, "token_type": "bearer"} - elif status_code == 403: - await asyncio.sleep(settings.FAILED_AUTH_WAIT_TIME) # prevents brute-force - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - raise HTTPException( - status_code=500, detail="Server error") - - -def property_where(owner: str, k: str, v: str): - """Build a SQL condition on a property, filtering by owner and eventually key and value - """ - conditions = ['owner=%s'] - params = [owner] - if k != '': - conditions.append('k=%s') - params.append(k) - if v != '': - conditions.append('v=%s') - params.append(v) - where = " AND ".join(conditions) - return where, params - - -@app.get("/products/stats", response_model=List[ProductStats]) -async def product_stats(response: Response, - owner='', k='', v='', - user: User = Depends(get_current_user)): - """ - Get the list of products with tags statistics - - The products list can be limited to some tags (k or k=v) - """ - check_owner_user(user, owner, allow_anonymous=True) - k, v = sanitize_data(k, v) - where, params = property_where(owner, k, v) - cur, timing = await db.db_exec(""" - SELECT json_agg(j.j)::json FROM( - SELECT json_build_object( - 'product',product, - 'keys',count(*), - 'last_edit',max(last_edit), - 'editors',count(distinct(editor)) - ) as j - FROM folksonomy - WHERE %s - GROUP BY product) as j; - """ % where, - params - ) - out = await cur.fetchone() - # cur, timing = await db.db_exec(""" - # SELECT count(*) - # FROM folksonomy; - # """ - # ) - # out2 = await cur.fetchone() - # import pdb;pdb.set_trace() - return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing":timing}) - - -@app.get("/products", response_model=List[ProductList]) -async def product_list(response: Response, - owner='', k='', v='', - user: User = Depends(get_current_user)): - """ - Get the list of products matching k or k=v - """ - if k == '': - return JSONResponse(status_code=422, content={"detail": {"msg": "missing value for k"}}) - check_owner_user(user, owner, allow_anonymous=True) - k, v = sanitize_data(k, v) - where, params = property_where(owner, k, v) - cur, timing = await db.db_exec(""" - SELECT coalesce(json_agg(j.j)::json, '[]'::json) FROM( - SELECT json_build_object( - 'product',product, - 'k',k, - 'v',v - ) as j - FROM folksonomy - WHERE %s - ) as j; - """ % where, - params - ) - out = await cur.fetchone() - return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing":timing}) - - -@app.get("/product/{product}", response_model=List[ProductTag]) -async def product_tags_list(response: Response, - product: str, owner='', - user: User = Depends(get_current_user)): - """ - Get a list of existing tags for a product - """ - - check_owner_user(user, owner, allow_anonymous=True) - cur, timing = await db.db_exec(""" - SELECT json_agg(j)::json FROM( - SELECT * FROM folksonomy WHERE product = %s AND owner = %s ORDER BY k - ) as j; - """, - (product, owner), - ) - out = await cur.fetchone() - return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) - - -@app.get("/product/{product}/{k}", response_model=ProductTag) -async def product_tag(response: Response, - product: str, k: str, owner='', - user: User = Depends(get_current_user)): - """ - Get a specific tag or tag hierarchy on a product - - - /product/xxx/key returns only the requested key - - /product/xxx/key* returns the key and subkeys (key:subkey) - """ - k, v = sanitize_data(k, None) - key = re.sub(r'[^a-z0-9_\:]', '', k) - check_owner_user(user, owner, allow_anonymous=True) - if k[-1:] == '*': - cur, timing = await db.db_exec( - """ - SELECT json_agg(j)::json FROM( - SELECT * - FROM folksonomy - WHERE product = %s AND owner = %s AND k ~ %s - ORDER BY k) as j; - """, - (product, owner, '^%s(:.|$)' % key), - ) - else: - cur, timing = await db.db_exec( - """ - SELECT row_to_json(j) FROM( - SELECT * - FROM folksonomy - WHERE product = %s AND owner = %s AND k = %s - ) as j; - """, - (product, owner, key), - ) - out = await cur.fetchone() - if out: - return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) - else: - return JSONResponse(status_code=404, content=None) - - -@app.get("/product/{product}/{k}/versions", response_model=List[ProductTag]) -async def product_tag_list_versions(response: Response, - product: str, k: str, owner='', - user: User = Depends(get_current_user)): - """ - Get a list of all versions of a tag for a product - """ - - check_owner_user(user, owner, allow_anonymous=True) - k, v = sanitize_data(k, None) - cur, timing = await db.db_exec( - """ - SELECT json_agg(j)::json FROM( - SELECT * - FROM folksonomy_versions - WHERE product = %s AND owner = %s AND k = %s - ORDER BY version DESC - ) as j; - """, - (product, owner, k), - ) - out = await cur.fetchone() - return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) - - -@app.post("/product") -async def product_tag_add(response: Response, - product_tag: ProductTag, - user: User = Depends(get_current_user)): - """ - Create a new product tag (version=1) - - - **product**: which product - - **k**: which key for the tag - - **v**: which value to set for the tag - - **version**: none or empty or 1 - - **owner**: none or empty for public tags, or your own user_id - - Be aware it's not possible to create the same tag twice. Though, you can update - a tag and add multiple values the way you want (don't forget to document how); comma - separated list is a good option. - """ - check_owner_user(user, product_tag.owner, allow_anonymous=False) - # enforce user - product_tag.editor = user.user_id - # note: version is checked by postgres routine - try: - query, params = db.create_product_tag_req(product_tag) - cur, timing = await db.db_exec(query, params) - except psycopg2.Error as e: - error_msg = re.sub(r'.*@@ (.*) @@\n.*$', r'\1', e.pgerror)[:-1] - return JSONResponse(status_code=422, content={"detail": {"msg": error_msg}}) - - if cur.rowcount == 1: - return "ok" - return - - -@app.put("/product") -async def product_tag_update(response: Response, - product_tag: ProductTag, - user: User = Depends(get_current_user)): - """ - Update a product tag - - - **product**: which product - - **k**: which key for the tag - - **v**: which value to set for the tag - - **version**: must be equal to previous version + 1 - - **owner**: None or empty for public tags, or your own user_id - """ - - check_owner_user(user, product_tag.owner, allow_anonymous=False) - # enforce user - product_tag.editor = user.user_id - try: - req, params = db.update_product_tag_req(product_tag) - cur, timing = await db.db_exec(req, params) - except psycopg2.Error as e: - raise HTTPException( - status_code=422, - detail=re.sub(r'.*@@ (.*) @@\n.*$', r'\1', e.pgerror)[:-1], - ) - if cur.rowcount == 1: - return "ok" - elif cur.rowcount == 0: # non existing key - raise HTTPException( - status_code=404, - detail="Key was not found", - ) - else: - raise HTTPException( - status_code=503, - detail="Doubious update - more than one row udpated", - ) - - -@app.delete("/product/{product}/{k}") -async def product_tag_delete(response: Response, - product: str, k: str, version: int, owner='', - user: User = Depends(get_current_user)): - """ - Delete a product tag - """ - check_owner_user(user, owner, allow_anonymous=False) - k, v = sanitize_data(k, None) - try: - # Setting version to 0, this is seen as a reset, - # while maintaining history in folksonomy_versions - cur, timing = await db.db_exec( - """ - UPDATE folksonomy SET version = 0, editor = %s, comment = 'DELETE' - WHERE product = %s AND owner = %s AND k = %s AND version = %s; - """, - (user.user_id, product, owner, k, version), - ) - except psycopg2.Error as e: - # note: transaction will be rolled back by the middleware - raise HTTPException( - status_code=422, - detail=re.sub(r'.*@@ (.*) @@\n.*$', r'\1', e.pgerror)[:-1], - ) - if cur.rowcount != 1: - raise HTTPException( - status_code=422, - detail="Unknown product/k/version for this owner", - ) - cur, timing = await db.db_exec( - """ - DELETE FROM folksonomy WHERE product = %s AND owner = %s AND k = %s AND version = 0; - """, - (product, owner, k.lower()), - ) - if cur.rowcount == 1: - return "ok" - else: - # we have a conflict, return an error explaining conflict - cur, timing = await db.db_exec( - """ - SELECT version FROM folksonomy WHERE product = %s AND owner = %s AND k = %s - """, - (product, owner, k) - ) - if cur.rowcount == 1: - out = await cur.fetchone() - raise HTTPException( - status_code=422, - detail="version mismatch, last version for this product/k is %s" % out[0], - ) - else: - raise HTTPException( - status_code=404, - detail="Unknown product/k for this owner", - ) - - -@app.get("/keys", response_model=List[KeyStats]) -async def keys_list(response: Response, - owner='', - user: User = Depends(get_current_user)): - """ - Get the list of keys with statistics - - The keys list can be restricted to private tags from some owner - """ - check_owner_user(user, owner, allow_anonymous=True) - cur, timing = await db.db_exec( - """ - SELECT json_agg(j.j)::json FROM( - SELECT json_build_object( - 'k',k, - 'count',count(*), - 'values',count(distinct(v)) - ) as j - FROM folksonomy - WHERE owner=%s - GROUP BY k - ORDER BY count(*) DESC) as j; - """, - (owner,) - ) - out = await cur.fetchone() - return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) - - -@app.get("/values/{k}") -async def get_unique_values(response: Response, - k: str, - owner: str = '', - q: str = '', - limit: int = '', - user: User = Depends(get_current_user)): - """ - Get the unique values of a given property and the corresponding number of products - - - **k**: The property key to get unique values for - - **owner**: None or empty for public tags, or your own user_id - - **q**: Filter values by a query string - - **limit**: Maximum number of values to return (default: 50; max: 1000) - """ - check_owner_user(user, owner, allow_anonymous=True) - k, _ = sanitize_data(k, None) - if not limit: - limit = 50 - if limit > 1000: - limit = 1000 - - sql = """ - SELECT json_agg(j.j)::json - FROM ( - SELECT json_build_object( - 'v', v, - 'product_count', count(*) - ) AS j - FROM folksonomy - WHERE owner=%s AND k=%s - """ - params = [owner, k] - - if q: - sql += " AND v ILIKE %s" - params.append(f"%{q}%") - - sql += """ - GROUP BY v - ORDER BY count(*) DESC - LIMIT %s - ) AS j; - """ - params.append(limit) - - cur, timing = await db.db_exec(sql, params) - out = await cur.fetchone() - data = out[0] if out and out[0] else [] - return JSONResponse(status_code=200, content=data, headers={"x-pg-timing": timing}) +# Add routers +app.include_router(auth.router) +app.include_router(stats.router) +app.include_router(product.router) @app.get("/ping") @@ -627,6 +92,6 @@ async def pong(response: Response): """ Check server health """ - cur, timing = await db.db_exec("SELECT current_timestamp AT TIME ZONE 'GMT'",()) + cur, timing = await db.db_exec("SELECT current_timestamp AT TIME ZONE 'GMT'", ()) pong = await cur.fetchone() - return {"ping": "pong @ %s" % pong[0]} \ No newline at end of file + return {"ping": "pong @ %s" % pong[0]} diff --git a/folksonomy/routes/__init__.py b/folksonomy/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/folksonomy/routes/auth.py b/folksonomy/routes/auth.py new file mode 100644 index 00000000..65de6155 --- /dev/null +++ b/folksonomy/routes/auth.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.security import OAuth2PasswordRequestForm +from fastapi.responses import JSONResponse +from typing import Optional +from fastapi.security import OAuth2PasswordBearer +import uuid +import asyncio +import aiohttp +from fastapi import Cookie +from folksonomy import db, settings + +router = APIRouter() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth", auto_error=False) + + +def get_auth_server(request: Request): + """ + Get auth server URL from request + + We deduce it by changing part of the request base URL + according to FOLKSONOMY_PREFIX and AUTH_PREFIX settings + """ + # For dev purposes, we can use a static auth server with AUTH_SERVER_STATIC + # which can be specified in local_settings.py + if hasattr(settings, 'AUTH_SERVER_STATIC') and settings.AUTH_SERVER_STATIC: + return settings.AUTH_SERVER_STATIC + base_url = f"{request.base_url.scheme}://{request.base_url.netloc}" + # remove folksonomy prefix and add AUTH prefix + base_url = base_url.replace( + settings.FOLKSONOMY_PREFIX or "", settings.AUTH_PREFIX or "") + return base_url + + +@router.post("/auth") +async def authentication(request: Request, response: Response, form_data: OAuth2PasswordRequestForm = Depends()): + """ + Authentication: provide user/password and get a bearer token in return + + - **username**: Open Food Facts user_id (not email) + - **password**: user password (clear text, but HTTPS encrypted) + + token is returned, to be used in later requests with usual "Authorization: bearer token" headers + """ + + user_id = form_data.username + password = form_data.password + token = user_id+'__U'+str(uuid.uuid4()) + auth_url = get_auth_server(request) + "/cgi/auth.pl" + auth_data = {'user_id': user_id, 'password': password} + async with aiohttp.ClientSession() as http_session: + async with http_session.post(auth_url, data=auth_data) as resp: + status_code = resp.status + if status_code == 200: + cur, timing = await db.db_exec(""" + DELETE FROM auth WHERE user_id = %s; + INSERT INTO auth (user_id, token, last_use) VALUES (%s,%s,current_timestamp AT TIME ZONE 'GMT'); + """, (user_id, user_id, token)) + if cur.rowcount == 1: + return {"access_token": token, "token_type": "bearer"} + elif status_code == 403: + # prevents brute-force + await asyncio.sleep(settings.FAILED_AUTH_WAIT_TIME) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={ + "WWW-Authenticate": "Bearer", + "x-auth-url": auth_url + }, + ) + elif status_code == 404: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid auth server: 404", + headers={ + "WWW-Authenticate": "Bearer", + "x-auth-url": auth_url + }, + ) + raise HTTPException( + status_code=500, detail="Server error") + + +@router.post("/auth_by_cookie") +async def authentication(request: Request, response: Response, session: Optional[str] = Cookie(None)): + """ + Authentication: provide Open Food Facts session cookie and get a bearer token in return + + - **session cookie**: Open Food Facts session cookie + + token is returned, to be used in later requests with usual "Authorization: bearer token" headers + """ + if not session or session == '': + raise HTTPException( + status_code=422, detail="Missing 'session' cookie") + + try: + session_data = session.split('&') + user_id = session_data[session_data.index('user_id') + 1] + token = user_id + '__U' + str(uuid.uuid4()) + except: + raise HTTPException( + status_code=422, detail="Malformed 'session' cookie") + + auth_url = get_auth_server(request) + "/cgi/auth.pl" + async with aiohttp.ClientSession() as http_session: + async with http_session.post(auth_url, cookies={'session': session}) as resp: + status_code = resp.status + + if status_code == 200: + cur, timing = await db.db_exec( + """ + DELETE FROM auth WHERE user_id = %s; + INSERT INTO auth (user_id, token, last_use) VALUES (%s,%s,current_timestamp AT TIME ZONE 'GMT'); + """, + (user_id, user_id, token) + ) + if cur.rowcount == 1: + return {"access_token": token, "token_type": "bearer"} + elif status_code == 403: + # prevents brute-force + await asyncio.sleep(settings.FAILED_AUTH_WAIT_TIME) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + raise HTTPException( + status_code=500, detail="Server error") diff --git a/folksonomy/routes/product.py b/folksonomy/routes/product.py new file mode 100644 index 00000000..8234907c --- /dev/null +++ b/folksonomy/routes/product.py @@ -0,0 +1,261 @@ + + +import re +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import JSONResponse +import psycopg2 + +from folksonomy import db +from folksonomy.models import ProductList, ProductTag, User +from folksonomy.utils.auth import check_owner_user, get_current_user +from folksonomy.utils.query import property_where, sanitize_data + +router = APIRouter() + + +@router.get("/products", response_model=List[ProductList]) +async def product_list(response: Response, + owner='', k='', v='', + user: User = Depends(get_current_user)): + """ + Get the list of products matching k or k=v + """ + if k == '': + return JSONResponse(status_code=422, content={"detail": {"msg": "missing value for k"}}) + check_owner_user(user, owner, allow_anonymous=True) + k, v = sanitize_data(k, v) + where, params = property_where(owner, k, v) + cur, timing = await db.db_exec(""" + SELECT coalesce(json_agg(j.j)::json, '[]'::json) FROM( + SELECT json_build_object( + 'product',product, + 'k',k, + 'v',v + ) as j + FROM folksonomy + WHERE %s + ) as j; + """ % where, + params + ) + out = await cur.fetchone() + return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) + + +@router.get("/product/{product}", response_model=List[ProductTag]) +async def product_tags_list(response: Response, + product: str, owner='', + user: User = Depends(get_current_user)): + """ + Get a list of existing tags for a product + """ + + check_owner_user(user, owner, allow_anonymous=True) + cur, timing = await db.db_exec(""" + SELECT json_agg(j)::json FROM( + SELECT * FROM folksonomy WHERE product = %s AND owner = %s ORDER BY k + ) as j; + """, + (product, owner), + ) + out = await cur.fetchone() + return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) + + +@router.get("/product/{product}/{k}", response_model=ProductTag) +async def product_tag(response: Response, + product: str, k: str, owner='', + user: User = Depends(get_current_user)): + """ + Get a specific tag or tag hierarchy on a product + + - /product/xxx/key returns only the requested key + - /product/xxx/key* returns the key and subkeys (key:subkey) + """ + k, v = sanitize_data(k, None) + key = re.sub(r'[^a-z0-9_\:]', '', k) + check_owner_user(user, owner, allow_anonymous=True) + if k[-1:] == '*': + cur, timing = await db.db_exec( + """ + SELECT json_agg(j)::json FROM( + SELECT * + FROM folksonomy + WHERE product = %s AND owner = %s AND k ~ %s + ORDER BY k) as j; + """, + (product, owner, '^%s(:.|$)' % key), + ) + else: + cur, timing = await db.db_exec( + """ + SELECT row_to_json(j) FROM( + SELECT * + FROM folksonomy + WHERE product = %s AND owner = %s AND k = %s + ) as j; + """, + (product, owner, key), + ) + out = await cur.fetchone() + if out: + return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) + else: + return JSONResponse(status_code=404, content=None) + + +@router.get("/product/{product}/{k}/versions", response_model=List[ProductTag]) +async def product_tag_list_versions(response: Response, + product: str, k: str, owner='', + user: User = Depends(get_current_user)): + """ + Get a list of all versions of a tag for a product + """ + + check_owner_user(user, owner, allow_anonymous=True) + k, v = sanitize_data(k, None) + cur, timing = await db.db_exec( + """ + SELECT json_agg(j)::json FROM( + SELECT * + FROM folksonomy_versions + WHERE product = %s AND owner = %s AND k = %s + ORDER BY version DESC + ) as j; + """, + (product, owner, k), + ) + out = await cur.fetchone() + return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) + + +@router.post("/product") +async def product_tag_add(response: Response, + product_tag: ProductTag, + user: User = Depends(get_current_user)): + """ + Create a new product tag (version=1) + + - **product**: which product + - **k**: which key for the tag + - **v**: which value to set for the tag + - **version**: none or empty or 1 + - **owner**: none or empty for public tags, or your own user_id + + Be aware it's not possible to create the same tag twice. Though, you can update + a tag and add multiple values the way you want (don't forget to document how); comma + separated list is a good option. + """ + check_owner_user(user, product_tag.owner, allow_anonymous=False) + # enforce user + product_tag.editor = user.user_id + # note: version is checked by postgres routine + try: + query, params = db.create_product_tag_req(product_tag) + cur, timing = await db.db_exec(query, params) + except psycopg2.Error as e: + error_msg = re.sub(r'.*@@ (.*) @@\n.*$', r'\1', e.pgerror)[:-1] + return JSONResponse(status_code=422, content={"detail": {"msg": error_msg}}) + + if cur.rowcount == 1: + return "ok" + return + + +@router.put("/product") +async def product_tag_update(response: Response, + product_tag: ProductTag, + user: User = Depends(get_current_user)): + """ + Update a product tag + + - **product**: which product + - **k**: which key for the tag + - **v**: which value to set for the tag + - **version**: must be equal to previous version + 1 + - **owner**: None or empty for public tags, or your own user_id + """ + + check_owner_user(user, product_tag.owner, allow_anonymous=False) + # enforce user + product_tag.editor = user.user_id + try: + req, params = db.update_product_tag_req(product_tag) + cur, timing = await db.db_exec(req, params) + except psycopg2.Error as e: + raise HTTPException( + status_code=422, + detail=re.sub(r'.*@@ (.*) @@\n.*$', r'\1', e.pgerror)[:-1], + ) + if cur.rowcount == 1: + return "ok" + elif cur.rowcount == 0: # non existing key + raise HTTPException( + status_code=404, + detail="Key was not found", + ) + else: + raise HTTPException( + status_code=503, + detail="Doubious update - more than one row udpated", + ) + + +@router.delete("/product/{product}/{k}") +async def product_tag_delete(response: Response, + product: str, k: str, version: int, owner='', + user: User = Depends(get_current_user)): + """ + Delete a product tag + """ + check_owner_user(user, owner, allow_anonymous=False) + k, v = sanitize_data(k, None) + try: + # Setting version to 0, this is seen as a reset, + # while maintaining history in folksonomy_versions + cur, timing = await db.db_exec( + """ + UPDATE folksonomy SET version = 0, editor = %s, comment = 'DELETE' + WHERE product = %s AND owner = %s AND k = %s AND version = %s; + """, + (user.user_id, product, owner, k, version), + ) + except psycopg2.Error as e: + # note: transaction will be rolled back by the middleware + raise HTTPException( + status_code=422, + detail=re.sub(r'.*@@ (.*) @@\n.*$', r'\1', e.pgerror)[:-1], + ) + if cur.rowcount != 1: + raise HTTPException( + status_code=422, + detail="Unknown product/k/version for this owner", + ) + cur, timing = await db.db_exec( + """ + DELETE FROM folksonomy WHERE product = %s AND owner = %s AND k = %s AND version = 0; + """, + (product, owner, k.lower()), + ) + if cur.rowcount == 1: + return "ok" + else: + # we have a conflict, return an error explaining conflict + cur, timing = await db.db_exec( + """ + SELECT version FROM folksonomy WHERE product = %s AND owner = %s AND k = %s + """, + (product, owner, k) + ) + if cur.rowcount == 1: + out = await cur.fetchone() + raise HTTPException( + status_code=422, + detail="version mismatch, last version for this product/k is %s" % out[0], + ) + else: + raise HTTPException( + status_code=404, + detail="Unknown product/k for this owner", + ) diff --git a/folksonomy/routes/stats.py b/folksonomy/routes/stats.py new file mode 100644 index 00000000..517480c3 --- /dev/null +++ b/folksonomy/routes/stats.py @@ -0,0 +1,129 @@ +from fastapi import APIRouter, Response, Depends +from fastapi.responses import JSONResponse +from typing import List + +from folksonomy import db +from folksonomy.models import KeyStats, ProductStats, User +from folksonomy.utils.auth import check_owner_user, get_current_user +from folksonomy.utils.query import property_where, sanitize_data + +router = APIRouter() + + +@router.get("/products/stats", response_model=List[ProductStats]) +async def product_stats(response: Response, + owner='', k='', v='', + user: User = Depends(get_current_user)): + """ + Get the list of products with tags statistics + + The products list can be limited to some tags (k or k=v) + """ + check_owner_user(user, owner, allow_anonymous=True) + k, v = sanitize_data(k, v) + where, params = property_where(owner, k, v) + cur, timing = await db.db_exec(""" + SELECT json_agg(j.j)::json FROM( + SELECT json_build_object( + 'product',product, + 'keys',count(*), + 'last_edit',max(last_edit), + 'editors',count(distinct(editor)) + ) as j + FROM folksonomy + WHERE %s + GROUP BY product) as j; + """ % where, + params + ) + out = await cur.fetchone() + # cur, timing = await db.db_exec(""" + # SELECT count(*) + # FROM folksonomy; + # """ + # ) + # out2 = await cur.fetchone() + # import pdb;pdb.set_trace() + return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) + + +@router.get("/keys", response_model=List[KeyStats]) +async def keys_list(response: Response, + owner='', + user: User = Depends(get_current_user)): + """ + Get the list of keys with statistics + + The keys list can be restricted to private tags from some owner + """ + check_owner_user(user, owner, allow_anonymous=True) + cur, timing = await db.db_exec( + """ + SELECT json_agg(j.j)::json FROM( + SELECT json_build_object( + 'k',k, + 'count',count(*), + 'values',count(distinct(v)) + ) as j + FROM folksonomy + WHERE owner=%s + GROUP BY k + ORDER BY count(*) DESC) as j; + """, + (owner,) + ) + out = await cur.fetchone() + return JSONResponse(status_code=200, content=out[0], headers={"x-pg-timing": timing}) + + +@router.get("/values/{k}") +async def get_unique_values(response: Response, + k: str, + owner: str = '', + q: str = '', + limit: int = '', + user: User = Depends(get_current_user)): + """ + Get the unique values of a given property and the corresponding number of products + + - **k**: The property key to get unique values for + - **owner**: None or empty for public tags, or your own user_id + - **q**: Filter values by a query string + - **limit**: Maximum number of values to return (default: 50; max: 1000) + """ + check_owner_user(user, owner, allow_anonymous=True) + k, _ = sanitize_data(k, None) + if not limit: + limit = 50 + if limit > 1000: + limit = 1000 + + sql = """ + SELECT json_agg(j.j)::json + FROM ( + SELECT json_build_object( + 'v', v, + 'product_count', count(*) + ) AS j + FROM folksonomy + WHERE owner=%s AND k=%s + """ + params = [owner, k] + + if q: + sql += " AND v ILIKE %s" + params.append(f"%{q}%") + + sql += """ + GROUP BY v + ORDER BY count(*) DESC + LIMIT %s + ) AS j; + """ + params.append(limit) + + cur, timing = await db.db_exec(sql, params) + out = await cur.fetchone() + data = out[0] if out and out[0] else [] + return JSONResponse(status_code=200, content=data, headers={"x-pg-timing": timing}) + diff --git a/folksonomy/utils/auth.py b/folksonomy/utils/auth.py new file mode 100644 index 00000000..2ab32051 --- /dev/null +++ b/folksonomy/utils/auth.py @@ -0,0 +1,50 @@ +from fastapi import Depends, HTTPException, status + +from folksonomy import db +from ..models import User +from fastapi.security import OAuth2PasswordBearer + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth", auto_error=False) + +async def get_current_user(token: str = Depends(oauth2_scheme)): + """ + Get current user and check token validity if present + """ + if token and '__U' in token: + cur = db.cursor() + await cur.execute( + "UPDATE auth SET last_use = current_timestamp AT TIME ZONE 'GMT' WHERE token = %s", ( + token,) + ) + if cur.rowcount == 1: + return User(user_id=token.split('__U', 1)[0]) + else: + return User(user_id=None) + + +def check_owner_user(user: User, owner, allow_anonymous=False): + """ + Check authentication depending on current user and 'owner' of the data + """ + user = user.user_id if user is not None else None + if user is None and allow_anonymous == False: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + if owner != '': + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required for '%s'" % owner, + headers={"WWW-Authenticate": "Bearer"}, + ) + if owner != user: + raise HTTPException( + status_code=422, + detail="owner should be '%s' or '' for public, but '%s' is authenticated" % ( + owner, user), + ) + return diff --git a/folksonomy/utils/query.py b/folksonomy/utils/query.py new file mode 100644 index 00000000..0479ba69 --- /dev/null +++ b/folksonomy/utils/query.py @@ -0,0 +1,21 @@ + +def sanitize_data(k, v): + """Some sanitization of data""" + k = k.strip() + v = v.strip() if v else v + return k, v + +def property_where(owner: str, k: str, v: str): + """Build a SQL condition on a property, filtering by owner and eventually key and value + """ + conditions = ['owner=%s'] + params = [owner] + if k != '': + conditions.append('k=%s') + params.append(k) + if v != '': + conditions.append('v=%s') + params.append(v) + where = " AND ".join(conditions) + return where, params +