Daniel Sheffield 1 год назад
Родитель
Сommit
ea7533d675
3 измененных файлов с 94 добавлено и 60 удалено
  1. 45 24
      app/rest/pyapi.py
  2. 42 29
      app/rest/route_decorators.py
  3. 7 7
      test/rest/test_Cache.py

+ 45 - 24
app/rest/pyapi.py

@@ -6,18 +6,22 @@
 import os
 import cherrypy
 from threading import Thread
-from typing import Union
+from typing import Tuple, Union
 from bottle import (
     route, request, response,
     static_file,
     FormsDict,
 )
+import cherrypy
 from psycopg import Cursor, connect
 from psycopg.rows import TupleRow
-from .route_decorators import normalize, poison, cursor
+from urllib.parse import parse_qs
+
+from .QueryCache import QueryCache
+from .route_decorators import cache, cursor
 from .query_to_xml import get_categories, get_groups, get_products, get_tags
 from .CachedLoadingPage import CachedLoadingPage
-from .Cache import Cache
+from .PageCache import PageCache
 from . import trend as worker
 
 host = f"host={os.getenv('HOST')}"
@@ -39,20 +43,37 @@ def trend_thread(conn, path, forms):
         )).start()
     return cb
 
+PAGE_CACHE = PageCache(10)
+QUERY_CACHE = QueryCache(None)
+
 @route('/grocery/trend', method=['GET', 'POST'])
-@poison(cache=Cache(10))
-@normalize
-def trend(key: str, forms: FormsDict, cache: Cache):
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
+def trend(key: Tuple[str, int], cache: PageCache):
     _, _, path, *_ = request.urlparts
-
+    cherrypy.log(f"key={key}")
     page = cache[key]
     if page is None:
-        page = cache.add(key, CachedLoadingPage([], trend_thread(conn, path, forms)))
+        form = key_to_form(key)
+        cherrypy.log(f"form={form}")
+        page = cache.add(key, CachedLoadingPage([], trend_thread(conn, path, form)))
     
     for i in iter_page(page):
         yield i
 
 
+def query_to_form(query):
+    form = FormsDict()
+    for k, v in parse_qs(query).items():
+        for item in v:
+            form.append(k, item)
+    return form
+
+
+def key_to_form(key):
+    query, _ = key
+    return query_to_form(query)
+
+
 def iter_page(page):
     # copy first to avoid races
     resp = list(page.value)
@@ -72,36 +93,36 @@ def iter_page(page):
 
 
 @route('/grocery/groups', method=['GET', 'POST'])
-@poison(cache=Cache(10))
-@normalize
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
 @cursor(connection=conn)
-def groups(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
+def groups(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
     response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return get_groups(cur, forms)
+    return get_groups(cur, form)
 
 
 @route('/grocery/categories', method=['GET', 'POST'])
-@poison(cache=Cache(10))
-@normalize
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
 @cursor(connection=conn)
-def categories(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
+def categories(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
     response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return get_categories(cur, forms)
+    return get_categories(cur, form)
 
 
 @route('/grocery/products', method=['GET', 'POST'])
-@poison(cache=Cache(10))
-@normalize
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
 @cursor(connection=conn)
-def products(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
+def products(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
     response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return get_products(cur, forms)
+    return get_products(cur, form)
 
 
 @route('/grocery/tags', method=['GET', 'POST'])
-@poison(cache=Cache(10))
-@normalize
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
 @cursor(connection=conn)
-def tags(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
+def tags(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
     response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return get_tags(cur, forms)
+    return get_tags(cur, form)

+ 42 - 29
app/rest/route_decorators.py

@@ -3,21 +3,25 @@
 # All rights reserved
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from typing import Callable, Iterable
+from typing import Callable, Iterable, Tuple
 from urllib.parse import urlencode
-from bottle import request, FormsDict, redirect
+from bottle import request, FormsDict, redirect, HTTPError
+import cherrypy
 from psycopg import Connection
 from psycopg.connection import TupleRow
 
+from .QueryCache import QueryCache, get_hash
 from ..data.filter import get_filter, get_query_param
 from . import BOOLEAN, PARAMS
-from .Cache import Cache, get_hash
+from .PageCache import PageCache
 from .hash_util import base32_to_hash, hash_to_base32, normalize_base32
 
-def normalize_query(query: FormsDict, allow: Iterable[str] = None) -> str:
-    if 'hash' in query and query.hash:
-        _hex = normalize_base32(query.hash)
-        return f'hash={_hex}'
+def normalize_query(query: FormsDict, allow: Iterable[str] = None) -> Tuple[str, str]:
+    if query.hash:
+        _hash = normalize_base32(query.hash)
+    else:
+        _hash = None
+
     allow = allow or PARAMS
     param = get_filter(query, allow=allow)
     norm = urlencode([
@@ -27,40 +31,49 @@ def normalize_query(query: FormsDict, allow: Iterable[str] = None) -> str:
             "organic", BOOLEAN[BOOLEAN.get(query.organic, None)]
         ) for k in sorted(param) if param[k]
     ])
-    return norm if len(norm) < 2000 else f'hash={hash_to_base32(get_hash(norm))}'
+    return norm if not _hash or len(query.keys()) > 1 else None, _hash
+
 
-def _normalize_decorator(func: Callable, allow=None):
+def _cache_decorator(func: Callable, query_cache: QueryCache = None, page_cache: PageCache = None):
     def wrap(*args, **kwargs):
         _, _, path, *_ = request.urlparts
-        normalized = normalize_query(request.params, allow=allow)
-        if request.query_string != normalized:
-            return redirect(f'{path}?{normalized}', 307)
         
-        _hash = request.params.hash
-        key = base32_to_hash(_hash) if _hash else request.query_string
-        return func(key, request.forms if _hash else request.query, *args, **kwargs)
-    return wrap
+        query, _hash = normalize_query(request.params)
+        if not _hash:
+            _hashInt = get_hash(query)
+            _hash = hash_to_base32(_hashInt)
+            key = (query, _hashInt)
+        else:
+            key = (query, base32_to_hash(_hash))
+        
+        if request.params.reload == "true":
+            page_cache.remove(key)
 
+        # key with tuple to avoid ambiguity
+        cached = query_cache.get(key)
+        if not cached:
+            if query:
+                cached = query_cache.add(key, None)
+            else:
+                return HTTPError(404, f"No query found for hash: {_hash}")
+        
+        cherrypy.log(f"cached query={cached}")
 
-def normalize(*args, **kwargs):
-    if not len(args):
-        return lambda f: _normalize_decorator(f, **kwargs)
-    
-    return _normalize_decorator(*args)
+        if not request.params.hash:
+            if cached and len(cached) > 2000:
+                return redirect(f"{path}?hash={_hash}")
 
+            if cached and request.query_string != cached:
+                return redirect(f"{path}?{cached}")
 
-def _poison_decorator(func: Callable, cache: Cache = None):
-    def wrap(*args, **kwargs):
-        if request.params.reload == 'true':
-            normalized = normalize_query(request.params, allow=PARAMS)
-            cache.remove(normalized)
-        return func(cache, *args, **kwargs)
+        return func((cached, key[1]), page_cache, *args, **kwargs)
     return wrap
 
 
-def poison(*args, cache=None, **kwargs):
+def cache(*args, **kwargs):
     if not len(args):
-        return lambda f: _poison_decorator(f, cache=cache, **kwargs)
+        return lambda f: _cache_decorator(f, **kwargs)
+    
     raise Exception("decorator argument required")
 
 

+ 7 - 7
test/rest/test_Cache.py

@@ -6,21 +6,21 @@
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 from pytest import fixture
 from time import time
-from app.rest.Cache import Cache
+from app.rest.PageCache import PageCache
 from app.rest.CachedLoadingPage import (
     CachedLoadingPage,
 )
 
 @fixture
 def cache():
-    return Cache(0)
+    return PageCache(0)
 
-def test_add(cache: Cache):
+def test_add(cache: PageCache):
     val = 'test-cached-value'
     key = 'test-key'
     assert cache.add(key, CachedLoadingPage(val, lambda _: None, incremental=False)).value == val
 
-def test_get(cache: Cache):
+def test_get(cache: PageCache):
     val = 'test-cached-value'
     key = 'test-key'
 
@@ -29,7 +29,7 @@ def test_get(cache: Cache):
     assert cache.add(key, CachedLoadingPage(val, lambda q: q.put('next-val'), incremental=False)).value == val
     assert cache.get(key).value == 'next-val'
 
-def test_remove(cache: Cache):
+def test_remove(cache: PageCache):
     val = 'test-cached-value'
     key = 'test-key'
 
@@ -38,7 +38,7 @@ def test_remove(cache: Cache):
     cache.remove(key)
     assert cache.get(key) is None
 
-def test_enforce_limit(cache: Cache):
+def test_enforce_limit(cache: PageCache):
     val = 'test-cached-value'
     key = 'test-key'
     assert cache.add(key, CachedLoadingPage(val, lambda _: None, incremental=False)).value == val
@@ -47,7 +47,7 @@ def test_enforce_limit(cache: Cache):
     assert cache.get(key) is None
     assert cache.get('other').value == val
 
-def test_clean_stale(cache: Cache):
+def test_clean_stale(cache: PageCache):
     val = 'test-cached-value'
     key = 'test-key'
     page = CachedLoadingPage(val, lambda _: None, incremental=False)