elwig-backend: Move OPEN_API_DOC to end of file
This commit is contained in:
@ -20,6 +20,354 @@ VERSION: str = '0.0.3'
|
|||||||
CNX: sqlite3.Cursor
|
CNX: sqlite3.Cursor
|
||||||
USER_FILE: str
|
USER_FILE: str
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Filter:
|
||||||
|
def __init__(self, name: str, values: list[int] or list[str] = None):
|
||||||
|
self.name = name
|
||||||
|
self.values = values
|
||||||
|
|
||||||
|
def is_int(self) -> bool:
|
||||||
|
return type(self.values[0]) is int
|
||||||
|
|
||||||
|
def is_str(self) -> bool:
|
||||||
|
return type(self.values[0]) is str
|
||||||
|
|
||||||
|
def is_single(self) -> bool:
|
||||||
|
return self.values is None
|
||||||
|
|
||||||
|
def to_sql_list(self) -> str:
|
||||||
|
if self.is_int():
|
||||||
|
return ', '.join(str(v) for v in self.values)
|
||||||
|
else:
|
||||||
|
return ', '.join(f"'{v}'" for v in self.values)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
if self.is_single():
|
||||||
|
return self.name
|
||||||
|
elif self.name == 'kgnr':
|
||||||
|
return f'kgnr={";".join(f"{v:05}" for v in self.values)}'
|
||||||
|
return f'{self.name}={";".join(str(v) for v in self.values)}'
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.__repr__()
|
||||||
|
|
||||||
|
def __eq__(self, other) -> bool:
|
||||||
|
return self.__repr__() == other.__repr__()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_str(string: str) -> Filter:
|
||||||
|
f = string.split('=', 1)
|
||||||
|
if len(f) == 2:
|
||||||
|
ps = f[1].split(';')
|
||||||
|
is_digit = all(p.isdigit() for p in ps)
|
||||||
|
return Filter(f[0], [int(p) for p in ps] if is_digit else ps)
|
||||||
|
return Filter(f[0])
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_regexp(pattern: str, value: Optional[str]) -> Optional[bool]:
|
||||||
|
return re.match(pattern, value) is not None if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def kmw_to_oe(kmw: float) -> float:
|
||||||
|
return kmw * (4.54 + 0.022 * kmw) if kmw is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def jdmp(value, is_bool: bool = False) -> str:
|
||||||
|
if is_bool and value:
|
||||||
|
return ' true'
|
||||||
|
elif is_bool and not value:
|
||||||
|
return 'false'
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_delivery_schedule_filter_clauses(filters: list[Filter]) -> list[str]:
|
||||||
|
clauses = []
|
||||||
|
for f in filters:
|
||||||
|
if f.name == 'year' and f.is_int():
|
||||||
|
clauses.append(f"s.year IN ({f.to_sql_list()})")
|
||||||
|
elif f.name == 'sortid' and f.is_str() and all(len(v) == 2 and v.isalpha() and v.isupper() for v in f.values):
|
||||||
|
clauses.append(f"v.sortid IN ({f.to_sql_list()})")
|
||||||
|
elif f.name == 'date' and f.is_str() and all(re.match(r'[0-9]{4}-[0-9]{2}-[0-9]{2}', v) is not None for v in f.values):
|
||||||
|
clauses.append(f"s.date IN ({f.to_sql_list()})")
|
||||||
|
else:
|
||||||
|
raise BadRequestError(f"Invalid filter '{f}'")
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
|
||||||
|
class ElwigApi(BaseHTTPRequestHandler):
|
||||||
|
def send(self, data: str, status_code: int = 200, url: str = None):
|
||||||
|
raw = data.encode('utf-8')
|
||||||
|
self.send_response(status_code)
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Headers', 'Authorization')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'HEAD, GET, OPTIONS')
|
||||||
|
if 300 <= status_code < 400 and status_code != 304:
|
||||||
|
self.send_header('Location', url)
|
||||||
|
elif status_code == 401:
|
||||||
|
self.send_header('WWW-Authenticate', 'Basic realm=Elwig')
|
||||||
|
if self.headers.get('Accept-Encoding') and len(data) > 64:
|
||||||
|
accept_encoding = [e.strip() for e in self.headers.get('Accept-Encoding').split(',')]
|
||||||
|
if 'gzip' in accept_encoding:
|
||||||
|
raw = gzip.compress(raw)
|
||||||
|
self.send_header('Content-Encoding', 'gzip')
|
||||||
|
self.send_header('Content-Type', 'application/json; charset=UTF-8')
|
||||||
|
self.send_header('Content-Length', str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
if self.request.type != 'HEAD' and self.request.type != 'OPTIONS':
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def error(self, status_code: int, message: str = None):
|
||||||
|
self.send(f'{{"message":{jdmp(message)}}}\n', status_code=status_code)
|
||||||
|
|
||||||
|
def see_other(self, url: str):
|
||||||
|
self.send(f'{{"url": {jdmp(url)}}}\n', status_code=303, url=url)
|
||||||
|
|
||||||
|
def authorize(self) -> (str or None, str or None):
|
||||||
|
try:
|
||||||
|
auth = self.headers.get('Authorization')
|
||||||
|
if auth is None or not auth.startswith('Basic '):
|
||||||
|
self.error(401, 'Unauthorized')
|
||||||
|
return None, None
|
||||||
|
auth = base64.b64decode(auth[6:]).split(b':', 1)
|
||||||
|
if len(auth) != 2:
|
||||||
|
self.error(401, 'Invalid Authorization header')
|
||||||
|
return None, None
|
||||||
|
username, password = auth[0].decode('utf-8'), auth[1].decode('utf-8')
|
||||||
|
except:
|
||||||
|
self.error(401, 'Invalid Authorization header')
|
||||||
|
return None, None
|
||||||
|
with open(USER_FILE, 'r') as file:
|
||||||
|
for line in file:
|
||||||
|
(u, r, p) = line.strip().split(':', 2)
|
||||||
|
if u == username:
|
||||||
|
if p == password:
|
||||||
|
return u, r
|
||||||
|
else:
|
||||||
|
self.error(401, 'Unauthorized')
|
||||||
|
return None, None
|
||||||
|
self.error(401, 'Unauthorized')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def exec_collection(self, sql_query: str, fmt: Callable, filters: list[Filter],
|
||||||
|
offset: int = None, limit: int = None,
|
||||||
|
distinct: tuple[[str], [int]] = None, sub_fmt: Callable = None):
|
||||||
|
with_clause = re.findall(r'(WITH .*?\))[\s\n]*SELECT', sql_query, flags=re.DOTALL)
|
||||||
|
if len(with_clause) > 0 and '.*' not in with_clause[0]:
|
||||||
|
with_clause = with_clause[0]
|
||||||
|
count_query = sql_query.replace(with_clause, '')
|
||||||
|
else:
|
||||||
|
with_clause = None
|
||||||
|
count_query = sql_query
|
||||||
|
count = f"""SELECT COUNT(DISTINCT {" || '|' || ".join(distinct[0])}) FROM""" if distinct else "SELECT COUNT(*) FROM"
|
||||||
|
count_query = re.sub(r"SELECT [^*]+? FROM", count, count_query, count=1, flags=re.DOTALL)
|
||||||
|
count_query = re.sub(r"(OFFSET|LIMIT) [0-9-]+", '', count_query)
|
||||||
|
count_query = re.sub(r"GROUP BY .*", '', count_query)
|
||||||
|
if with_clause:
|
||||||
|
count_query = with_clause + ' ' + count_query
|
||||||
|
count = CNX.execute(count_query).fetchone()
|
||||||
|
count = count[0] if count is not None else 0
|
||||||
|
|
||||||
|
if limit is not None:
|
||||||
|
if "LIMIT " in sql_query:
|
||||||
|
sql_query = re.sub(r"LIMIT [0-9-]+", f"LIMIT {limit}", sql_query)
|
||||||
|
else:
|
||||||
|
sql_query += f" LIMIT {limit}"
|
||||||
|
if offset is not None:
|
||||||
|
if "OFFSET " in sql_query:
|
||||||
|
sql_query = re.sub(r"OFFSET [0-9-]+", f"OFFSET {offset}", sql_query)
|
||||||
|
else:
|
||||||
|
sql_query += f" OFFSET {offset}"
|
||||||
|
rows = CNX.execute(sql_query)
|
||||||
|
|
||||||
|
data = (f'''{{"filters":[{','.join(f'{{"filter":{jdmp(str(f))}}}' for f in filters)}],'''
|
||||||
|
f'"total":{count},"offset":{offset or 0},"limit":{jdmp(limit)},'
|
||||||
|
f'"data":[')
|
||||||
|
first, first_, cur, last = True, True, None, None
|
||||||
|
for r in rows or []:
|
||||||
|
cur = tuple([r[i] for i in distinct[1]]) if distinct else None
|
||||||
|
if not distinct or cur != last:
|
||||||
|
first_ = True
|
||||||
|
if first:
|
||||||
|
first = False
|
||||||
|
else:
|
||||||
|
if distinct and sub_fmt:
|
||||||
|
data += '\n ]}'
|
||||||
|
data += ','
|
||||||
|
data += f'\n ' + fmt(r)
|
||||||
|
if distinct and sub_fmt:
|
||||||
|
if first_:
|
||||||
|
data += '['
|
||||||
|
first_ = False
|
||||||
|
else:
|
||||||
|
data += ','
|
||||||
|
data += f'\n ' + sub_fmt(r)
|
||||||
|
last = cur
|
||||||
|
if distinct and sub_fmt and not first:
|
||||||
|
data += '\n ]}'
|
||||||
|
data += '\n]}\n'
|
||||||
|
self.send(data)
|
||||||
|
|
||||||
|
def do_GET_delivery_schedules(self, filters: list[Filter], offset: int = None, limit: int = None, order: str = None):
|
||||||
|
clauses = get_delivery_schedule_filter_clauses(filters)
|
||||||
|
sql = f"""
|
||||||
|
WITH announcements
|
||||||
|
AS (SELECT year, dsnr, SUM(weight) AS weight
|
||||||
|
FROM delivery_announcement
|
||||||
|
GROUP BY year, dsnr)
|
||||||
|
SELECT s.year, s.dsnr, s.date, s.description, s.max_weight, s.cancelled,
|
||||||
|
COALESCE(a.weight, 0) AS announced_weight,
|
||||||
|
COALESCE(SUM(p.weight), 0) AS delivered_weight,
|
||||||
|
STRFTIME('%Y-%m-%dT%H:%M:%SZ', DATETIME(s.ancmt_from, 'unixepoch')),
|
||||||
|
STRFTIME('%Y-%m-%dT%H:%M:%SZ', DATETIME(s.ancmt_to, 'unixepoch')),
|
||||||
|
b.zwstid, b.name,
|
||||||
|
s.attrid, s.cultid
|
||||||
|
FROM delivery_schedule s
|
||||||
|
LEFT JOIN branch b ON b.zwstid = s.zwstid
|
||||||
|
LEFT JOIN announcements a ON (a.year, a.dsnr) = (s.year, s.dsnr)
|
||||||
|
LEFT JOIN delivery_schedule_wine_variety v ON (v.year, v.dsnr) = (s.year, s.dsnr)
|
||||||
|
LEFT JOIN delivery d ON (d.date, d.zwstid) = (s.date, s.zwstid)
|
||||||
|
LEFT JOIN delivery_part p ON (p.year, p.did) = (d.year, d.did) AND p.sortid = v.sortid
|
||||||
|
"""
|
||||||
|
if len(clauses) > 0:
|
||||||
|
sql += f" WHERE {' AND '.join(clauses)}"
|
||||||
|
sql += " GROUP BY s.year, s.dsnr"
|
||||||
|
sql += " ORDER BY s.year, s.date, s.zwstid, s.description, s.dsnr"
|
||||||
|
|
||||||
|
rows1 = CNX.execute("""
|
||||||
|
SELECT date, zwstid, cultid, SUM(weight)
|
||||||
|
FROM delivery d
|
||||||
|
JOIN delivery_part p ON (p.year, p.did) = (d.year, d.did)
|
||||||
|
WHERE (d.date, d.zwstid, COALESCE(p.cultid, '')) IN
|
||||||
|
(SELECT date, zwstid, COALESCE(cultid, '') FROM delivery_schedule GROUP BY date, zwstid, cultid HAVING COUNT(*) = 1)
|
||||||
|
GROUP BY date, zwstid, cultid
|
||||||
|
""")
|
||||||
|
days1 = {(r[0], r[1], r[2]): r[3] for r in rows1}
|
||||||
|
rows2 = CNX.execute("""
|
||||||
|
SELECT date, zwstid, attrid, SUM(weight)
|
||||||
|
FROM delivery d
|
||||||
|
JOIN delivery_part p ON (p.year, p.did) = (d.year, d.did)
|
||||||
|
WHERE (d.date, d.zwstid, COALESCE(p.attrid, '')) IN
|
||||||
|
(SELECT date, zwstid, COALESCE(attrid, '') FROM delivery_schedule GROUP BY date, zwstid, attrid HAVING COUNT(*) = 1)
|
||||||
|
GROUP BY date, zwstid, attrid
|
||||||
|
""")
|
||||||
|
days2 = {(r[0], r[1], r[2]): r[3] for r in rows2}
|
||||||
|
|
||||||
|
self.exec_collection(
|
||||||
|
sql,
|
||||||
|
lambda r: f'{{"year":{r[0]:4},"dsnr":{r[1]:2},"date":"{r[2]}",'
|
||||||
|
f'"branch":{{"zwstid":{jdmp(r[10])},"name":{jdmp(r[11]):20}}},'
|
||||||
|
f'"description":{jdmp(r[3]):50},'
|
||||||
|
f'"max_weight":{jdmp(r[4]):>6},'
|
||||||
|
f'"is_cancelled":{jdmp(r[5], is_bool=True)},'
|
||||||
|
f'"announced_weight":{r[6]:6},'
|
||||||
|
f'"delivered_weight":{days1.get((r[2], r[10], r[13]), days2.get((r[2], r[10], r[12]), r[7] or 0)):6},'
|
||||||
|
f'"announcement_from":{jdmp(r[8])},'
|
||||||
|
f'"announcement_to":{jdmp(r[9])}}}',
|
||||||
|
filters, offset, limit, distinct=(['s.year', 's.dsnr'], [1, 2]))
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self.do_GET()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send('')
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
try:
|
||||||
|
if self.path == '/':
|
||||||
|
openapi_json = f'https://{self.headers.get("Host", "localhost")}/elwig/api/v1/openapi.json'
|
||||||
|
self.see_other(f'https://validator.swagger.io/?url={openapi_json}')
|
||||||
|
return
|
||||||
|
elif self.path == '/openapi.json':
|
||||||
|
self.send(OPEN_API_DOC)
|
||||||
|
return
|
||||||
|
|
||||||
|
username, role = self.authorize()
|
||||||
|
if not username or not role:
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = self.path.split('?', 1)
|
||||||
|
if len(parts) == 1:
|
||||||
|
path, query = parts[0], {}
|
||||||
|
else:
|
||||||
|
path, query = parts[0], {urllib.parse.unquote(s[0]): urllib.parse.unquote(s[-1])
|
||||||
|
for s in [p.split('=', 1) for p in parts[1].split('&')]}
|
||||||
|
filters = [Filter.from_str(f) for f in (query['filters'].split(',') if 'filters' in query else [])]
|
||||||
|
try:
|
||||||
|
offset = int(query['offset']) if 'offset' in query else None
|
||||||
|
limit = int(query['limit']) if 'limit' in query else None
|
||||||
|
except ValueError:
|
||||||
|
raise BadRequestError('Invalid integer value in query')
|
||||||
|
order = query['order'] if 'order' in query else None
|
||||||
|
|
||||||
|
if path == '/wine/varieties':
|
||||||
|
self.exec_collection(
|
||||||
|
"SELECT sortid, type, name, comment FROM wine_variety",
|
||||||
|
lambda r: f'{{"sortid":{jdmp(r[0])},"type":{jdmp(r[1])},"name":{jdmp(r[2])},"comment":{jdmp(r[3])}}}',
|
||||||
|
[], offset, limit)
|
||||||
|
elif path == '/wine/quality_levels':
|
||||||
|
self.exec_collection(
|
||||||
|
"SELECT qualid, name, min_kmw, predicate FROM wine_quality_level",
|
||||||
|
lambda r: f'{{"qualid":{jdmp(r[0])},"name":{jdmp(r[1]):22},"min_kmw":{jdmp(r[2])},"is_predicate":{jdmp(r[3], is_bool=True)}}}',
|
||||||
|
[], offset, limit)
|
||||||
|
elif path == '/wine/attributes':
|
||||||
|
self.exec_collection(
|
||||||
|
"SELECT attrid, name FROM wine_attribute",
|
||||||
|
lambda r: f'{{"attrid":{jdmp(r[0]):4},"name":{jdmp(r[1])}}}',
|
||||||
|
[], offset, limit)
|
||||||
|
elif path == '/wine/cultivations':
|
||||||
|
self.exec_collection(
|
||||||
|
"SELECT cultid, name, description FROM wine_cultivation",
|
||||||
|
lambda r: f'{{"cultid":{jdmp(r[0]):5},"name":{jdmp(r[1])},"description":{jdmp(r[2])}}}',
|
||||||
|
[], offset, limit)
|
||||||
|
elif path == '/modifiers':
|
||||||
|
self.exec_collection(
|
||||||
|
"SELECT year, modid, name, ordering FROM modifier",
|
||||||
|
lambda r: f'{{"year":{jdmp(r[0])},"modid":{jdmp(r[1]):5},"name":{jdmp(r[2]):18},"ordering":{jdmp(r[3])}}}',
|
||||||
|
[], offset, limit)
|
||||||
|
elif path == '/delivery_schedules':
|
||||||
|
self.do_GET_delivery_schedules(filters, offset, limit, order)
|
||||||
|
else:
|
||||||
|
self.error(404, 'Invalid path')
|
||||||
|
except BadRequestError as e:
|
||||||
|
self.error(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exception(e)
|
||||||
|
self.error(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
global CNX
|
||||||
|
global USER_FILE
|
||||||
|
|
||||||
|
sqlite3.register_adapter(datetime.date, lambda d: d.strftime('%Y-%m-%d'))
|
||||||
|
sqlite3.register_adapter(datetime.time, lambda t: t.strftime('%H:%M:%S'))
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('db', type=str, metavar='DB')
|
||||||
|
parser.add_argument('user_file', type=str, metavar='USER-FILE')
|
||||||
|
parser.add_argument('-p', '--port', type=int, default=8080)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
USER_FILE = args.user_file
|
||||||
|
CNX = sqlite3.connect(f'file:{args.db}?mode=ro', uri=True)
|
||||||
|
CNX.create_function('REGEXP', 2, sqlite_regexp, deterministic=True)
|
||||||
|
|
||||||
|
server = HTTPServer(('localhost', args.port), ElwigApi)
|
||||||
|
print(f'Listening on http://localhost:{args.port}')
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except InterruptedError:
|
||||||
|
print()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
server.server_close()
|
||||||
|
print('Good bye!')
|
||||||
|
|
||||||
|
|
||||||
OPEN_API_DOC: str = '''{
|
OPEN_API_DOC: str = '''{
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
@ -413,353 +761,6 @@ OPEN_API_DOC: str = '''{
|
|||||||
'''.replace('[VERSION]', VERSION)
|
'''.replace('[VERSION]', VERSION)
|
||||||
|
|
||||||
|
|
||||||
class BadRequestError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Filter:
|
|
||||||
def __init__(self, name: str, values: list[int] or list[str] = None):
|
|
||||||
self.name = name
|
|
||||||
self.values = values
|
|
||||||
|
|
||||||
def is_int(self) -> bool:
|
|
||||||
return type(self.values[0]) is int
|
|
||||||
|
|
||||||
def is_str(self) -> bool:
|
|
||||||
return type(self.values[0]) is str
|
|
||||||
|
|
||||||
def is_single(self) -> bool:
|
|
||||||
return self.values is None
|
|
||||||
|
|
||||||
def to_sql_list(self) -> str:
|
|
||||||
if self.is_int():
|
|
||||||
return ', '.join(str(v) for v in self.values)
|
|
||||||
else:
|
|
||||||
return ', '.join(f"'{v}'" for v in self.values)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
if self.is_single():
|
|
||||||
return self.name
|
|
||||||
elif self.name == 'kgnr':
|
|
||||||
return f'kgnr={";".join(f"{v:05}" for v in self.values)}'
|
|
||||||
return f'{self.name}={";".join(str(v) for v in self.values)}'
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.__repr__()
|
|
||||||
|
|
||||||
def __eq__(self, other) -> bool:
|
|
||||||
return self.__repr__() == other.__repr__()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_str(string: str) -> Filter:
|
|
||||||
f = string.split('=', 1)
|
|
||||||
if len(f) == 2:
|
|
||||||
ps = f[1].split(';')
|
|
||||||
is_digit = all(p.isdigit() for p in ps)
|
|
||||||
return Filter(f[0], [int(p) for p in ps] if is_digit else ps)
|
|
||||||
return Filter(f[0])
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_regexp(pattern: str, value: Optional[str]) -> Optional[bool]:
|
|
||||||
return re.match(pattern, value) is not None if value is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def kmw_to_oe(kmw: float) -> float:
|
|
||||||
return kmw * (4.54 + 0.022 * kmw) if kmw is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def jdmp(value, is_bool: bool = False) -> str:
|
|
||||||
if is_bool and value:
|
|
||||||
return ' true'
|
|
||||||
elif is_bool and not value:
|
|
||||||
return 'false'
|
|
||||||
return json.dumps(value, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_delivery_schedule_filter_clauses(filters: list[Filter]) -> list[str]:
|
|
||||||
clauses = []
|
|
||||||
for f in filters:
|
|
||||||
if f.name == 'year' and f.is_int():
|
|
||||||
clauses.append(f"s.year IN ({f.to_sql_list()})")
|
|
||||||
elif f.name == 'sortid' and f.is_str() and all(len(v) == 2 and v.isalpha() and v.isupper() for v in f.values):
|
|
||||||
clauses.append(f"v.sortid IN ({f.to_sql_list()})")
|
|
||||||
elif f.name == 'date' and f.is_str() and all(re.match(r'[0-9]{4}-[0-9]{2}-[0-9]{2}', v) is not None for v in f.values):
|
|
||||||
clauses.append(f"s.date IN ({f.to_sql_list()})")
|
|
||||||
else:
|
|
||||||
raise BadRequestError(f"Invalid filter '{f}'")
|
|
||||||
return clauses
|
|
||||||
|
|
||||||
|
|
||||||
class ElwigApi(BaseHTTPRequestHandler):
|
|
||||||
def send(self, data: str, status_code: int = 200, url: str = None):
|
|
||||||
raw = data.encode('utf-8')
|
|
||||||
self.send_response(status_code)
|
|
||||||
self.send_header('Access-Control-Allow-Origin', '*')
|
|
||||||
self.send_header('Access-Control-Allow-Headers', 'Authorization')
|
|
||||||
self.send_header('Access-Control-Allow-Methods', 'HEAD, GET, OPTIONS')
|
|
||||||
if 300 <= status_code < 400 and status_code != 304:
|
|
||||||
self.send_header('Location', url)
|
|
||||||
elif status_code == 401:
|
|
||||||
self.send_header('WWW-Authenticate', 'Basic realm=Elwig')
|
|
||||||
if self.headers.get('Accept-Encoding') and len(data) > 64:
|
|
||||||
accept_encoding = [e.strip() for e in self.headers.get('Accept-Encoding').split(',')]
|
|
||||||
if 'gzip' in accept_encoding:
|
|
||||||
raw = gzip.compress(raw)
|
|
||||||
self.send_header('Content-Encoding', 'gzip')
|
|
||||||
self.send_header('Content-Type', 'application/json; charset=UTF-8')
|
|
||||||
self.send_header('Content-Length', str(len(raw)))
|
|
||||||
self.end_headers()
|
|
||||||
if self.request.type != 'HEAD' and self.request.type != 'OPTIONS':
|
|
||||||
self.wfile.write(raw)
|
|
||||||
|
|
||||||
def error(self, status_code: int, message: str = None):
|
|
||||||
self.send(f'{{"message":{jdmp(message)}}}\n', status_code=status_code)
|
|
||||||
|
|
||||||
def see_other(self, url: str):
|
|
||||||
self.send(f'{{"url": {jdmp(url)}}}\n', status_code=303, url=url)
|
|
||||||
|
|
||||||
def authorize(self) -> (str or None, str or None):
|
|
||||||
try:
|
|
||||||
auth = self.headers.get('Authorization')
|
|
||||||
if auth is None or not auth.startswith('Basic '):
|
|
||||||
self.error(401, 'Unauthorized')
|
|
||||||
return None, None
|
|
||||||
auth = base64.b64decode(auth[6:]).split(b':', 1)
|
|
||||||
if len(auth) != 2:
|
|
||||||
self.error(401, 'Invalid Authorization header')
|
|
||||||
return None, None
|
|
||||||
username, password = auth[0].decode('utf-8'), auth[1].decode('utf-8')
|
|
||||||
except:
|
|
||||||
self.error(401, 'Invalid Authorization header')
|
|
||||||
return None, None
|
|
||||||
with open(USER_FILE, 'r') as file:
|
|
||||||
for line in file:
|
|
||||||
(u, r, p) = line.strip().split(':', 2)
|
|
||||||
if u == username:
|
|
||||||
if p == password:
|
|
||||||
return u, r
|
|
||||||
else:
|
|
||||||
self.error(401, 'Unauthorized')
|
|
||||||
return None, None
|
|
||||||
self.error(401, 'Unauthorized')
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def exec_collection(self, sql_query: str, fmt: Callable, filters: list[Filter],
|
|
||||||
offset: int = None, limit: int = None,
|
|
||||||
distinct: tuple[[str], [int]] = None, sub_fmt: Callable = None):
|
|
||||||
with_clause = re.findall(r'(WITH .*?\))[\s\n]*SELECT', sql_query, flags=re.DOTALL)
|
|
||||||
if len(with_clause) > 0 and '.*' not in with_clause[0]:
|
|
||||||
with_clause = with_clause[0]
|
|
||||||
count_query = sql_query.replace(with_clause, '')
|
|
||||||
else:
|
|
||||||
with_clause = None
|
|
||||||
count_query = sql_query
|
|
||||||
count = f"""SELECT COUNT(DISTINCT {" || '|' || ".join(distinct[0])}) FROM""" if distinct else "SELECT COUNT(*) FROM"
|
|
||||||
count_query = re.sub(r"SELECT [^*]+? FROM", count, count_query, count=1, flags=re.DOTALL)
|
|
||||||
count_query = re.sub(r"(OFFSET|LIMIT) [0-9-]+", '', count_query)
|
|
||||||
count_query = re.sub(r"GROUP BY .*", '', count_query)
|
|
||||||
if with_clause:
|
|
||||||
count_query = with_clause + ' ' + count_query
|
|
||||||
count = CNX.execute(count_query).fetchone()
|
|
||||||
count = count[0] if count is not None else 0
|
|
||||||
|
|
||||||
if limit is not None:
|
|
||||||
if "LIMIT " in sql_query:
|
|
||||||
sql_query = re.sub(r"LIMIT [0-9-]+", f"LIMIT {limit}", sql_query)
|
|
||||||
else:
|
|
||||||
sql_query += f" LIMIT {limit}"
|
|
||||||
if offset is not None:
|
|
||||||
if "OFFSET " in sql_query:
|
|
||||||
sql_query = re.sub(r"OFFSET [0-9-]+", f"OFFSET {offset}", sql_query)
|
|
||||||
else:
|
|
||||||
sql_query += f" OFFSET {offset}"
|
|
||||||
rows = CNX.execute(sql_query)
|
|
||||||
|
|
||||||
data = (f'''{{"filters":[{','.join(f'{{"filter":{jdmp(str(f))}}}' for f in filters)}],'''
|
|
||||||
f'"total":{count},"offset":{offset or 0},"limit":{jdmp(limit)},'
|
|
||||||
f'"data":[')
|
|
||||||
first, first_, cur, last = True, True, None, None
|
|
||||||
for r in rows or []:
|
|
||||||
cur = tuple([r[i] for i in distinct[1]]) if distinct else None
|
|
||||||
if not distinct or cur != last:
|
|
||||||
first_ = True
|
|
||||||
if first:
|
|
||||||
first = False
|
|
||||||
else:
|
|
||||||
if distinct and sub_fmt:
|
|
||||||
data += '\n ]}'
|
|
||||||
data += ','
|
|
||||||
data += f'\n ' + fmt(r)
|
|
||||||
if distinct and sub_fmt:
|
|
||||||
if first_:
|
|
||||||
data += '['
|
|
||||||
first_ = False
|
|
||||||
else:
|
|
||||||
data += ','
|
|
||||||
data += f'\n ' + sub_fmt(r)
|
|
||||||
last = cur
|
|
||||||
if distinct and sub_fmt and not first:
|
|
||||||
data += '\n ]}'
|
|
||||||
data += '\n]}\n'
|
|
||||||
self.send(data)
|
|
||||||
|
|
||||||
def do_GET_delivery_schedules(self, filters: list[Filter], offset: int = None, limit: int = None, order: str = None):
|
|
||||||
clauses = get_delivery_schedule_filter_clauses(filters)
|
|
||||||
sql = f"""
|
|
||||||
WITH announcements
|
|
||||||
AS (SELECT year, dsnr, SUM(weight) AS weight
|
|
||||||
FROM delivery_announcement
|
|
||||||
GROUP BY year, dsnr)
|
|
||||||
SELECT s.year, s.dsnr, s.date, s.description, s.max_weight, s.cancelled,
|
|
||||||
COALESCE(a.weight, 0) AS announced_weight,
|
|
||||||
COALESCE(SUM(p.weight), 0) AS delivered_weight,
|
|
||||||
STRFTIME('%Y-%m-%dT%H:%M:%SZ', DATETIME(s.ancmt_from, 'unixepoch')),
|
|
||||||
STRFTIME('%Y-%m-%dT%H:%M:%SZ', DATETIME(s.ancmt_to, 'unixepoch')),
|
|
||||||
b.zwstid, b.name,
|
|
||||||
s.attrid, s.cultid
|
|
||||||
FROM delivery_schedule s
|
|
||||||
LEFT JOIN branch b ON b.zwstid = s.zwstid
|
|
||||||
LEFT JOIN announcements a ON (a.year, a.dsnr) = (s.year, s.dsnr)
|
|
||||||
LEFT JOIN delivery_schedule_wine_variety v ON (v.year, v.dsnr) = (s.year, s.dsnr)
|
|
||||||
LEFT JOIN delivery d ON (d.date, d.zwstid) = (s.date, s.zwstid)
|
|
||||||
LEFT JOIN delivery_part p ON (p.year, p.did) = (d.year, d.did) AND p.sortid = v.sortid
|
|
||||||
"""
|
|
||||||
if len(clauses) > 0:
|
|
||||||
sql += f" WHERE {' AND '.join(clauses)}"
|
|
||||||
sql += " GROUP BY s.year, s.dsnr"
|
|
||||||
sql += " ORDER BY s.year, s.date, s.zwstid, s.description, s.dsnr"
|
|
||||||
|
|
||||||
rows1 = CNX.execute("""
|
|
||||||
SELECT date, zwstid, cultid, SUM(weight)
|
|
||||||
FROM delivery d
|
|
||||||
JOIN delivery_part p ON (p.year, p.did) = (d.year, d.did)
|
|
||||||
WHERE (d.date, d.zwstid, COALESCE(p.cultid, '')) IN
|
|
||||||
(SELECT date, zwstid, COALESCE(cultid, '') FROM delivery_schedule GROUP BY date, zwstid, cultid HAVING COUNT(*) = 1)
|
|
||||||
GROUP BY date, zwstid, cultid
|
|
||||||
""")
|
|
||||||
days1 = {(r[0], r[1], r[2]): r[3] for r in rows1}
|
|
||||||
rows2 = CNX.execute("""
|
|
||||||
SELECT date, zwstid, attrid, SUM(weight)
|
|
||||||
FROM delivery d
|
|
||||||
JOIN delivery_part p ON (p.year, p.did) = (d.year, d.did)
|
|
||||||
WHERE (d.date, d.zwstid, COALESCE(p.attrid, '')) IN
|
|
||||||
(SELECT date, zwstid, COALESCE(attrid, '') FROM delivery_schedule GROUP BY date, zwstid, attrid HAVING COUNT(*) = 1)
|
|
||||||
GROUP BY date, zwstid, attrid
|
|
||||||
""")
|
|
||||||
days2 = {(r[0], r[1], r[2]): r[3] for r in rows2}
|
|
||||||
|
|
||||||
self.exec_collection(
|
|
||||||
sql,
|
|
||||||
lambda r: f'{{"year":{r[0]:4},"dsnr":{r[1]:2},"date":"{r[2]}",'
|
|
||||||
f'"branch":{{"zwstid":{jdmp(r[10])},"name":{jdmp(r[11]):20}}},'
|
|
||||||
f'"description":{jdmp(r[3]):50},'
|
|
||||||
f'"max_weight":{jdmp(r[4]):>6},'
|
|
||||||
f'"is_cancelled":{jdmp(r[5], is_bool=True)},'
|
|
||||||
f'"announced_weight":{r[6]:6},'
|
|
||||||
f'"delivered_weight":{days1.get((r[2], r[10], r[13]), days2.get((r[2], r[10], r[12]), r[7] or 0)):6},'
|
|
||||||
f'"announcement_from":{jdmp(r[8])},'
|
|
||||||
f'"announcement_to":{jdmp(r[9])}}}',
|
|
||||||
filters, offset, limit, distinct=(['s.year', 's.dsnr'], [1, 2]))
|
|
||||||
|
|
||||||
def do_HEAD(self):
|
|
||||||
self.do_GET()
|
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
|
||||||
self.send('')
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
try:
|
|
||||||
if self.path == '/':
|
|
||||||
openapi_json = f'https://{self.headers.get("Host", "localhost")}/elwig/api/v1/openapi.json'
|
|
||||||
self.see_other(f'https://validator.swagger.io/?url={openapi_json}')
|
|
||||||
return
|
|
||||||
elif self.path == '/openapi.json':
|
|
||||||
self.send(OPEN_API_DOC)
|
|
||||||
return
|
|
||||||
|
|
||||||
username, role = self.authorize()
|
|
||||||
if not username or not role:
|
|
||||||
return
|
|
||||||
|
|
||||||
parts = self.path.split('?', 1)
|
|
||||||
if len(parts) == 1:
|
|
||||||
path, query = parts[0], {}
|
|
||||||
else:
|
|
||||||
path, query = parts[0], {urllib.parse.unquote(s[0]): urllib.parse.unquote(s[-1])
|
|
||||||
for s in [p.split('=', 1) for p in parts[1].split('&')]}
|
|
||||||
filters = [Filter.from_str(f) for f in (query['filters'].split(',') if 'filters' in query else [])]
|
|
||||||
try:
|
|
||||||
offset = int(query['offset']) if 'offset' in query else None
|
|
||||||
limit = int(query['limit']) if 'limit' in query else None
|
|
||||||
except ValueError:
|
|
||||||
raise BadRequestError('Invalid integer value in query')
|
|
||||||
order = query['order'] if 'order' in query else None
|
|
||||||
|
|
||||||
if path == '/wine/varieties':
|
|
||||||
self.exec_collection(
|
|
||||||
"SELECT sortid, type, name, comment FROM wine_variety",
|
|
||||||
lambda r: f'{{"sortid":{jdmp(r[0])},"type":{jdmp(r[1])},"name":{jdmp(r[2])},"comment":{jdmp(r[3])}}}',
|
|
||||||
[], offset, limit)
|
|
||||||
elif path == '/wine/quality_levels':
|
|
||||||
self.exec_collection(
|
|
||||||
"SELECT qualid, name, min_kmw, predicate FROM wine_quality_level",
|
|
||||||
lambda r: f'{{"qualid":{jdmp(r[0])},"name":{jdmp(r[1]):22},"min_kmw":{jdmp(r[2])},"is_predicate":{jdmp(r[3], is_bool=True)}}}',
|
|
||||||
[], offset, limit)
|
|
||||||
elif path == '/wine/attributes':
|
|
||||||
self.exec_collection(
|
|
||||||
"SELECT attrid, name FROM wine_attribute",
|
|
||||||
lambda r: f'{{"attrid":{jdmp(r[0]):4},"name":{jdmp(r[1])}}}',
|
|
||||||
[], offset, limit)
|
|
||||||
elif path == '/wine/cultivations':
|
|
||||||
self.exec_collection(
|
|
||||||
"SELECT cultid, name, description FROM wine_cultivation",
|
|
||||||
lambda r: f'{{"cultid":{jdmp(r[0]):5},"name":{jdmp(r[1])},"description":{jdmp(r[2])}}}',
|
|
||||||
[], offset, limit)
|
|
||||||
elif path == '/modifiers':
|
|
||||||
self.exec_collection(
|
|
||||||
"SELECT year, modid, name, ordering FROM modifier",
|
|
||||||
lambda r: f'{{"year":{jdmp(r[0])},"modid":{jdmp(r[1]):5},"name":{jdmp(r[2]):18},"ordering":{jdmp(r[3])}}}',
|
|
||||||
[], offset, limit)
|
|
||||||
elif path == '/delivery_schedules':
|
|
||||||
self.do_GET_delivery_schedules(filters, offset, limit, order)
|
|
||||||
else:
|
|
||||||
self.error(404, 'Invalid path')
|
|
||||||
except BadRequestError as e:
|
|
||||||
self.error(400, str(e))
|
|
||||||
except Exception as e:
|
|
||||||
traceback.print_exception(e)
|
|
||||||
self.error(500, str(e))
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
global CNX
|
|
||||||
global USER_FILE
|
|
||||||
|
|
||||||
sqlite3.register_adapter(datetime.date, lambda d: d.strftime('%Y-%m-%d'))
|
|
||||||
sqlite3.register_adapter(datetime.time, lambda t: t.strftime('%H:%M:%S'))
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument('db', type=str, metavar='DB')
|
|
||||||
parser.add_argument('user_file', type=str, metavar='USER-FILE')
|
|
||||||
parser.add_argument('-p', '--port', type=int, default=8080)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
USER_FILE = args.user_file
|
|
||||||
CNX = sqlite3.connect(f'file:{args.db}?mode=ro', uri=True)
|
|
||||||
CNX.create_function('REGEXP', 2, sqlite_regexp, deterministic=True)
|
|
||||||
|
|
||||||
server = HTTPServer(('localhost', args.port), ElwigApi)
|
|
||||||
print(f'Listening on http://localhost:{args.port}')
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
except InterruptedError:
|
|
||||||
print()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print()
|
|
||||||
server.server_close()
|
|
||||||
print('Good bye!')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user