diff --git a/.gitignore b/.gitignore index 08ea110..7b1f0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,7 @@ dmypy.json # production /build +coach_db.json # misc .DS_Store @@ -148,4 +149,4 @@ dmypy.json npm-debug.log* yarn-debug.log* -yarn-error.log* \ No newline at end of file +yarn-error.log* diff --git a/docs/http-api.md b/docs/http-api.md new file mode 100644 index 0000000..df61c36 --- /dev/null +++ b/docs/http-api.md @@ -0,0 +1,114 @@ +# HTTP API + +The general URL format is `http[s]:///api//?=&=&...`. + +The API should be RESTful: +- GET - get data about '\' corresponding to parameters given in the URL +- POST - create/update '\' with data given in a JSON body +- DELETE - delete '\' corresponding to parameters given in the URL + + +## Coaches + +The 'coaches' object supports POST, GET and DELETE. + +`/api/v1/coaches` + +The JSON fields used to represent a coach are as follows: + - name: string + - bio: string + - available: boolean + - birth_year: integer + - gender: string ('male', 'female' or 'other') + - languages: object {\:string: \:integer} + - need: list \[integer\] + - rights: list \[integer\] + - housing: list \[integer\] + + +### Create new coach (POST) + +`POST /api/v1/coaches` + +#### Request body + +JSON object with fields from the coach representation (all *required*). + +#### Response + +JSON object with the following fields: + - \ + - id: integer + + +### Edit coach (POST) + +`POST /api/v1/coaches/` + +#### Request body + +JSON object with any fields from the coach representation (all *optional*). + +#### Response + +JSON object with the following fields: + - \ + - id: integer + + +### Fetch all coaches (GET) + +`GET /api/v1/coaches/` + +#### Response + +List of JSON objects with the following fields: + - \ + - id: integer + + +### Look up coach by ID (GET) + +`GET /api/v1/coaches/` + +#### Response + +JSON object with the following fields: + - \ + - id: integer + + +### Delete coach (DELETE) + +`DELETE /api/v1/coaches/` + +#### Response + +Empty. + + + +## Coach matches + +The 'coach-matches' object supports GET only. This corresponds to looking up coaches based on their suitability to a set of parameters. + +`/api/v1/coach-matches` + +### Look up coach matches (GET) + +`GET /api/v1/coach-matches?` + +Parameters are as follows (all *optional*): + - birth_year: integer + - gender: string ('male', 'female' or 'other') + - languages: string (a comma separated list of "\:\") + - need: integer + - rights: integer + - housing: integer + +#### Response + +A list of JSON objects in order of match score. Each element in the list has the following fields: + - \ + - id: integer + - match_score: integer diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..32844e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +aiofiles==0.5.0 +certifi==2020.6.20 +chardet==3.0.4 +contextvars==2.4 +h11==0.9.0 +h2==3.2.0 +hpack==3.0.0 +hstspreload==2020.8.18 +httptools==0.1.1 +httpx==0.11.1 +hyperframe==5.2.0 +idna==2.10 +immutables==0.14 +multidict==4.7.6 +rfc3986==1.4.0 +sanic==20.6.3 +sniffio==1.1.0 +ujson==3.1.0 +urllib3==1.25.10 +uvloop==0.14.0 +websockets==8.1 diff --git a/server.py b/server.py index cc61bf8..96dd674 100644 --- a/server.py +++ b/server.py @@ -1,36 +1,392 @@ +import argparse +import json +import logging +import os +import sys +from collections import namedtuple +from typing import Collection, Dict, List, Optional, Tuple, Union + import sanic -import functools +import sanic.request +import sanic.response + +logger = logging.getLogger(__name__) app = sanic.Sanic("hello_example") -app.static('/', 'build/') +app.static("/", "build/") + + +# JSON type for coach data. +CoachJSON = Dict[str, Union[str, int, bool, float, "CoachJSON"]] + +# ------------------------------------------------------------------------------ +# Database +# ------------------------------------------------------------------------------ + + +class Coach( + namedtuple( + "_Coach", + [ + "id", + "name", + "bio", + "available", + "birth_year", + "gender", + "languages", + "need", + "rights", + "housing", + ], + ) +): + """A coach object - an entry in the database.""" + + def __new__( + cls, + *, + id: Optional[int] = None, + name: str, + bio: str, + available: bool = True, + birth_year: int, + gender: str, + languages: Dict[str, int], + need: Collection[int], + rights: Collection[int], + housing: Collection[int], + ): + # TODO: Validate args. + return super().__new__( + cls, + id=id, + name=name, + bio=bio, + available=available, + birth_year=birth_year, + gender=gender, + languages=languages, + need=need, + rights=rights, + housing=housing, + ) + + def to_json(self) -> CoachJSON: + return self._asdict() -def check_request_for_authorization_status(request): - sanic.log.logger.info(f"Checking authorization: IP is {request.ip}") - match = (request.ip == "127.0.0.1") - sanic.log.logger.info(f"Check IP matches 127.0.0.1: {match}") + @classmethod + def from_json(cls, obj: CoachJSON) -> "Coach": + return cls(**obj) - return match + def copy(self) -> "Coach": + return self.from_json(self.to_json()) -def authorized(f): - @functools.wraps(f) - async def decorated_function(request, *args, **kwargs): - is_authorized = check_request_for_authorization_status(request) - if is_authorized: - # Run the handler method and return the response. - response = await f(request, *args, **kwargs) - return response +class CoachDB: + """The in-memory coach database.""" + + def __init__( + self, file: Optional[os.PathLike] = None, *, auto_persist: bool = True + ): + """ + :param file: + File to use as persistent storage of the DB. If not found it will be + created. + :param auto_persist: + Whether to automatically persist the DB to file on operations that + modify the DB. Ignored if file is not given. + Set this to False if manual batching is desired. + """ + self._db: Dict[int, Coach] = dict() + self._file = file + self._auto_persist = auto_persist and file + self._next_id: int = 0 + if file: + self._load_from_file(file) + + def __iter__(self): + return iter(self._db.values()) + + def __contains__(self, item: Union[int, Coach]): + if item in self._db: + return True + elif isinstance(item, Coach): + return item.id in self._db else: - # The user is not authorized, so respond with 403. - return sanic.response.json({'status': 'not_authorized'}, 403) + return False + + def add(self, coach: Coach) -> Coach: + """Add a new coach to the DB, setting the ID.""" + assert self._next_id not in self._db + if coach.id is not None: + raise ValueError("Coach already has an ID assigned, not adding to DB") + db_coach = Coach(**{**coach.to_json(), "id": self._next_id}) + self._next_id += 1 + self._db[db_coach.id] = db_coach + logger.debug("Added coach with ID %d", coach.id) + if self._auto_persist: + self.persist(self._file) + return db_coach + + def remove(self, coach_id: int): + """Remove a coach from the DB by ID.""" + try: + self._db.pop(coach_id) + except KeyError: + raise KeyError(f"Coach with ID '{coach_id}' not found") + else: + logger.debug("Removed coach with ID %d", coach_id) + if self._auto_persist: + self.persist(self._file) + + def get(self, coach_id: int) -> Coach: + """Get a coach from the DB by ID.""" + return self._db[coach_id] + + def update_entry(self, coach: Coach) -> Coach: + """Update a given coach entry.""" + if coach.id not in self._db: + raise KeyError(f"Coach ID '{coach.id}' not found in DB") + self._db[coach.id] = coach + logger.debug("Updated coach with ID %d", coach.id) + if self._auto_persist: + self.persist(self._file) + return coach + + def persist(self, file: os.PathLike) -> None: + """Persist the database to a given file.""" + logger.debug("Saving coaches DB to file: %s", file) + data = dict(next_id=self._next_id, data=[c.to_json() for c in self]) + with open(file, "w") as f: + json.dump(data, f) + + def _load_from_file(self, file: os.PathLike): + """Load data in from a given file, or create the file if it doesn't exist.""" + try: + with open(file, "r") as f: + data = json.load(f) + logger.info("Loading in coaches DB from file: %s", file) + self._db = {c["id"]: Coach.from_json(c) for c in data["data"]} + self._next_id = data["next_id"] + except FileNotFoundError: + logger.info("Coaches DB file not found, creating now: %s", file) + self.persist(file) + + +# The in-memory database of coaches. +coach_db: CoachDB + +# The file used to persist the coach DB. +COACH_DB_FILE = "./coach_db.json" + + +# ------------------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------------------ + + +def _parse_languages(languages: str) -> Dict[str, int]: + ret = dict() + if not languages: + return ret + for lang in languages.split(","): + lang_name, proficiency = lang.split(":") + ret[lang_name] = int(proficiency) + return ret + + +# ------------------------------------------------------------------------------ +# Route handlers +# ------------------------------------------------------------------------------ + + +def get_coaches() -> List[Coach]: + return list(coach_db) + + +def get_coach(coach_id: int) -> Coach: + return coach_db.get(coach_id) + + +def create_coach(data: CoachJSON) -> Coach: + coach = Coach(**data) + db_coach = coach_db.add(coach) + return db_coach + + +def edit_coach(coach_id: int, data: CoachJSON) -> Coach: + kwargs = get_coach(coach_id).to_json() + kwargs.update(data) + updated_coach = Coach(**kwargs) + db_coach = coach_db.update_entry(updated_coach) + return db_coach + + +def delete_coach(coach_id: int) -> None: + coach_db.remove(coach_id) + + +def get_coach_matches(data: CoachJSON) -> List[Tuple[int, Coach]]: + """ + Get coaches based on how well they match the given data. + + :param data: + Data to match on. + :return: + A list of tuples containing the match score and the matched coach. + """ + + def get_lang_match_score(c: Coach, languages: Dict[str, int]) -> int: + """ + Calculate the match score between a coach and a set of language + proficiencies. + + The score is determined based on the single language that has the best + match (minimum score of 0). + + The formula used is: + 30 - 2 * * + where the proficiencies are assumed to be small integers with lower + numbers corresponding to higher proficiency. + + This means the range of scores given is 0-29. + + Example: + Coach has english:1,spanish:2,french:4 + Other has english:5,spanish:3,french:3 + Language compatibility scores are english:20,spanish:18,french:6 + Overall score is 20 (for English). + """ + match_langs = [L for L in languages if L in c.languages] + return max((0, *[30 - 2 * languages[L] * c.languages[L] for L in match_langs])) + + coach_matches = [] + for coach in get_coaches(): + if not coach.available: + continue + match_score = 0 + match_score += get_lang_match_score(coach, data.get("languages", [])) + if data.get("need") in coach.need: + match_score += 20 + if data.get("rights") in coach.rights: + match_score += 10 + if data.get("housing") in coach.housing: + match_score += 10 + if data.get("gender") == coach.gender: + match_score += 5 + if "birth_year" in data: + match_score += max(0, 5 - abs(data["birth_year"] - coach.birth_year) // 2) + coach_matches.append((match_score, coach)) + + return sorted(coach_matches, reverse=True) + + +# ------------------------------------------------------------------------------ +# Routes +# ------------------------------------------------------------------------------ + + +COMMON_HEADERS = {"Access-Control-Allow-Origin": "http://localhost:3000"} - return decorated_function @app.route("/") -@authorized -async def index(request): - return await sanic.response.file("build/index.html") +async def index(request: sanic.request.Request) -> sanic.response.HTTPResponse: + return await sanic.response.file("build/index.html") + + +@app.route("/api/v1/coaches", methods=["GET", "POST"]) +@app.route("/api/v1/coaches/", methods=["GET", "POST", "DELETE"]) +async def api_coaches( + request: sanic.request.Request, coach_id: Optional[int] = None +) -> sanic.response.HTTPResponse: + """HTTP API for 'coaches'.""" + try: + if request.method == "GET": + if coach_id is None: + coaches = get_coaches() + response = sanic.response.json([c.to_json() for c in coaches]) + else: + coach = get_coach(coach_id) + response = sanic.response.json(coach.to_json()) + elif request.method == "POST": + if coach_id is None: + coach = create_coach(request.json) + else: + coach = edit_coach(coach_id, request.json) + response = sanic.response.json(coach.to_json()) + elif request.method == "DELETE": + delete_coach(coach_id) + response = sanic.response.empty() + else: + assert False + except Exception: + logger.exception( + "Unexpected exception on %s route", request.path, exc_info=True + ) + response = sanic.response.text("Error processing request", status=400) + + response.headers = sanic.response.Header(COMMON_HEADERS) + return response + + +@app.route("/api/v1/coach-matches", methods=["GET"]) +async def api_coach_matches( + request: sanic.request.Request, +) -> sanic.response.HTTPResponse: + """HTTP API for 'coach-matches'.""" + try: + args = {k: v[0] for k, v in request.args.items()} + for int_arg in ["birth_year", "need", "rights", "housing"]: + if int_arg in args: + args[int_arg] = int(args[int_arg]) + if "languages" in args: + args["languages"] = _parse_languages(args["languages"]) + coach_matches = get_coach_matches(args) + response = sanic.response.json( + [ + {**coach.to_json(), "match_score": score} + for score, coach in coach_matches + ] + ) + except Exception: + logger.exception( + "Unexpected exception on %s route", request.path, exc_info=True + ) + response = sanic.response.text("Error processing request", status=400) + + response.headers = sanic.response.Header(COMMON_HEADERS) + return response + + +# ------------------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------------------ + + +def parse_args(argv: List[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--dev", action="store_true", help="Run in developer mode") + parser.add_argument("--debug", action="store_true", help="Turn on debug logging") + return parser.parse_args(argv) + + +def main(argv: List[str]): + global coach_db + + args = parse_args(argv) + + logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) + + coach_db = CoachDB(COACH_DB_FILE) + + if args.dev: + port = 8000 + else: + port = 80 + app.run(host="0.0.0.0", port=port) + if __name__ == "__main__": - app.run(host="0.0.0.0", port=8000) \ No newline at end of file + main(sys.argv[1:]) diff --git a/src/components/Assign.js b/src/components/Assign.js index c168fd7..b8b488f 100644 --- a/src/components/Assign.js +++ b/src/components/Assign.js @@ -61,9 +61,15 @@ export default class Assign extends React.Component { buttons: [ { label: 'Confirm', - onClick: () => console.log( - "Confirmed form: " + JSON.stringify(this.state) - ) + onClick: () => { + // TODO: Generate URL with params (make sure to use a proper API + // so that spaces etc. are properly handled!). + // TODO: Hardcode to localhost:8000 for development mode. + fetch('/api/v1/coach-matches?birth_year=1979&gender=male&languages=english:1,french:3&need=1&rights=2&housing=3') + .then(response => response.json()) + .then(data => console.log(data)) + console.log("Confirmed form: " + JSON.stringify(this.state)); + } }, { label: 'Go back', diff --git a/test-server.sh b/test-server.sh new file mode 100644 index 0000000..638b73f --- /dev/null +++ b/test-server.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -e + + +coach_json () { + echo """{ + \"name\": \"$1\", + \"bio\": \"$2\", + \"available\": true, + \"birth_year\": $3, + \"gender\": \"$4\", + \"languages\": {$5}, + \"need\": [1, 2, 3], + \"rights\": [2], + \"housing\": [3] + }""" +} + + +echo "List empty DB" +echo "-------------" +curl localhost:8000/api/v1/coaches +echo +echo + +echo "Create coaches" +echo "--------------" +curl -H "Content-Type: application/json" -d "$(coach_json Bob 'Hey, Bob here.' 1992 male '"english":1,"spanish":4')" localhost:8000/api/v1/coaches +echo +curl -H "Content-Type: application/json" -d "$(coach_json Albert '' 1990 other)" localhost:8000/api/v1/coaches +echo +curl -H "Content-Type: application/json" -d "$(coach_json Kelly '' 1988 female)" localhost:8000/api/v1/coaches +echo +curl -H "Content-Type: application/json" -d "$(coach_json Susan '' 1993 female)" localhost:8000/api/v1/coaches +echo +curl -H "Content-Type: application/json" -d "$(coach_json Mike '' 1970 male '"spanish":1,"french":2')" localhost:8000/api/v1/coaches +echo +echo + +echo "List DB" +echo "-------" +curl localhost:8000/api/v1/coaches +echo +echo + +echo "Get coach by ID" +echo "---------------" +curl localhost:8000/api/v1/coaches/1 +echo +echo + +echo "Modify coach" +echo "------------" +curl -H "Content-Type: application/json" -d '{"name":"Kelly S."}' localhost:8000/api/v1/coaches/2 +echo +echo + +echo "Remove coach" +echo "------------" +curl --request DELETE localhost:8000/api/v1/coaches/3 +echo +echo + + +echo "List DB" +echo "-------" +curl localhost:8000/api/v1/coaches +echo +echo + + +echo "Coach matches for birth_year=34, languages=english:2, gender=male" +echo "-----------------------------------------------------------------" +curl "localhost:8000/api/v1/coach-matches?birth_year=1990&languages=english:2&gender=male" +echo +echo +