Browse Source

Initial work on the API for the lab...

This gets the basic framework together, and testing infrastructure.

Basic bearer authentication is implemented and working.
main
John-Mark Gurney 4 years ago
commit
0b5546dd8e
8 changed files with 287 additions and 0 deletions
  1. +6
    -0
      .gitignore
  2. +11
    -0
      Makefile
  3. +4
    -0
      README.md
  4. +183
    -0
      bitelab/__init__.py
  5. +47
    -0
      bitelab/config.py
  6. +2
    -0
      fixtures/api_keys
  7. +4
    -0
      requirements.txt
  8. +30
    -0
      setup.py

+ 6
- 0
.gitignore View File

@@ -0,0 +1,6 @@
.coverage

*.pyc

bitelab.egg-info
p

+ 11
- 0
Makefile View File

@@ -0,0 +1,11 @@
MODULES=bitelab
VIRTUALENV?=virtualenv-3.8

test:
(ls $(MODULES)/*.py | entr sh -c 'python -m coverage run -m unittest $(basename $(MODULES)) && coverage report --omit=p/\* -m -i')

env:
($(VIRTUALENV) p && . ./p/bin/activate && pip install -r requirements.txt)

run:
($(VIRTUALENV) p && . ./p/bin/activate && hypercorn --bind '0.0.0.0:7742' --bind '[::]:7742' --bind 'unix:/tmp/bitelab.sock' 'bitelab:getApp()')

+ 4
- 0
README.md View File

@@ -0,0 +1,4 @@
BITELAB
=======

TODO

+ 183
- 0
bitelab/__init__.py View File

@@ -0,0 +1,183 @@
from typing import Optional
from functools import lru_cache, wraps

from fastapi import APIRouter, Depends, FastAPI, Request
from fastapi.security import OAuth2PasswordBearer
from httpx import AsyncClient, Auth
from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_401_UNAUTHORIZED

from . import config

import asyncio
import gc
import socket
import sys
import unittest

# fix up parse_socket_addr for hypercorn
from hypercorn.utils import parse_socket_addr
from hypercorn.asyncio import tcp_server
def new_parse_socket_addr(domain, addr):
if domain == socket.AF_UNIX:
return (addr, -1)

return parse_socket_addr(domain, addr)

tcp_server.parse_socket_addr = new_parse_socket_addr

class BoardManager(object):
board_classes = [ 'cora-z7s' ]

def __init__(self, settings):
self._settings = settings

def classes(self):
return self.board_classes

def unhashable_lru():
def newwrapper(fun):
cache = {}

@wraps(fun)
def wrapper(*args, **kwargs):
idargs = tuple(id(x) for x in args)
idkwargs = tuple(sorted((k, id(v)) for k, v in
kwargs.items()))
k = (idargs, idkwargs)
if k in cache:
realargs, realkwargs, res = cache[k]
if all(x is y for x, y in zip(args,
realargs)) and all(realkwargs[x] is
kwargs[x] for x in realkwargs):
return res

res = fun(*args, **kwargs)
cache[k] = (args, kwargs, res)

return res

return wrapper

return newwrapper

class BiteAuth(Auth):
def __init__(self, token):
self.token = token

def auth_flow(self, request):
request.headers['Authorization'] = 'Bearer ' + self.token
yield request

@lru_cache()
def get_settings():
return config.Settings()

@unhashable_lru()
def get_boardmanager(settings: config.Settings = Depends(get_settings)):
return BoardManager(settings)

oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/nonexistent')

def lookup_user(token: str = Depends(oauth2_scheme), settings: config.Settings = Depends(get_settings)):
try:
return settings.apikeytouser(token)
except KeyError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid authentication credentials',
headers={'WWW-Authenticate': 'Bearer'},
)

router = APIRouter()

def board_priority(request: Request):
# Get the board, if any, from the connection
scope = request.scope
return scope['server']

@router.get('/board_classes')
async def foo(user: str = Depends(lookup_user), brdmgr: BoardManager = Depends(get_boardmanager)):
return brdmgr.classes()

@router.get('/board_info')
async def foo(user: str = Depends(lookup_user), brdmgr: BoardManager = Depends(get_boardmanager)):
return brdmgr.classes()

@router.get('/')
async def foo(board_prio: dict = Depends(board_priority), settings: config.Settings = Depends(get_settings)):
return { 'foo': 'bar', 'board': board_prio }

def getApp():
app = FastAPI()
app.include_router(router)

return app

# uvicorn can't call the above function, while hypercorn can
#app = getApp()

class TestUnhashLRU(unittest.TestCase):
def test_unhashlru(self):
lsta = []
lstb = []

# that a wrapped function
cachefun = unhashable_lru()(lambda x: object())

# handles unhashable objects
resa = cachefun(lsta)
resb = cachefun(lstb)

# that they return the same object again
self.assertIs(resa, cachefun(lsta))
self.assertIs(resb, cachefun(lstb))

# that the object returned is not the same
self.assertIsNot(cachefun(lsta), cachefun(lstb))

# that a second wrapped funcion
cachefun2 = unhashable_lru()(lambda x: object())

# does not return the same object as the first cache
self.assertIsNot(cachefun(lsta), cachefun2(lsta))

# Note: this will not work under python before 3.8 before
# IsolatedAsyncioTestCase was added. The tearDown has to happen
# with the event loop running, otherwise the task and other things
# do not get cleaned up properly.
class TestBiteLab(unittest.IsolatedAsyncioTestCase):
async def get_settings_override(self):
# Note: this gets run on each request.
return config.Settings(apikeyfile="fixtures/api_keys")

def setUp(self):
self.app = getApp()
self.app.dependency_overrides[get_settings] = self.get_settings_override
self.client = AsyncClient(app=self.app, base_url='http://testserver')

def tearDown(self):
self.app = None
asyncio.run(self.client.aclose())
self.client = None

async def test_config(self):
settings = await self.get_settings_override()
self.assertEqual(settings.apikeytouser('thisisanapikey'), 'foo')
self.assertEqual(settings.apikeytouser('anotherlongapikey'), 'bar')

async def test_basic(self):
res = await self.client.get('/')
self.assertNotEqual(res.status_code, HTTP_404_NOT_FOUND)
self.assertEqual(res.json(), { 'foo': 'bar', 'board': [ 'testserver', None ] })

async def test_notauth(self):
res = await self.client.get('/board_classes')
self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)

res = await self.client.get('/board_info')
self.assertEqual(res.status_code, HTTP_401_UNAUTHORIZED)

async def test_classes(self):
res = await self.client.get('/board_classes', auth=BiteAuth('thisisanapikey'))
self.assertEqual(res.status_code, HTTP_200_OK)
self.assertEqual(res.json(), [ 'cora-z7s' ])

+ 47
- 0
bitelab/config.py View File

@@ -0,0 +1,47 @@
from pydantic import BaseSettings

import asyncio
import aiokq

# How to deal w/ private vars:
# https://web.archive.org/web/20201113005838/https://github.com/samuelcolvin/pydantic/issues/655
class Settings(BaseSettings):
__slots__ = ('_apikeydb', '_updatetask', '_fp', )
apikeyfile: str

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

fp = open(self.apikeyfile)
self._sync_updateapikeys(fp)
updtsk = asyncio.create_task(aiokq.run_on_modify(fp,
self._updateapikeys))

object.__setattr__(self, '_fp', fp)
object.__setattr__(self, '_updatetask', updtsk)

# Mark this as covered because coverage says the code isn't run,
# despite the fact that it does get run (and can trigger an
# exception)
def __del__(self): # pragma: no cover
self._updatetask.cancel()
# XXX - looks like task is done before getting here
self._fp.close()

async def _updateapikeys(self, fp):
self._sync_updateapikeys(fp)

def _sync_updateapikeys(self, fp):
fp.seek(0)
keydb = {}
for line in fp.readlines():
user, key = line.split()
keydb[key] = user

object.__setattr__(self, '_apikeydb', keydb)

def apikeytouser(self, key):
return self._apikeydb[key]

class Config:
env_file = ".env"

+ 2
- 0
fixtures/api_keys View File

@@ -0,0 +1,2 @@
foo thisisanapikey
bar anotherlongapikey

+ 4
- 0
requirements.txt View File

@@ -0,0 +1,4 @@
# use setup.py for dependancy info
-e .

-e .[dev]

+ 30
- 0
setup.py View File

@@ -0,0 +1,30 @@

# python setup.py --dry-run --verbose install

import os.path
from setuptools import setup, find_packages

from distutils.core import setup

setup(
name='bitelab',
version='0.1.0',
author='John-Mark Gurney',
author_email='jmg@FreeBSD.org',
packages=find_packages(),
#url='',
license='BSD',
description='Build Integrity and Testing of Embedded systems LAB',
#download_url='',
long_description=open('README.md').read(),
install_requires=[
'fastapi',
'httpx',
'hypercorn', # option, for server only?
'pydantic[dotenv]',
'aiokq @ git+https://www.funkthat.com/gitea/jmg/aiokq.git'
],
extras_require = {
'dev': [ 'coverage' ],
},
)

Loading…
Cancel
Save