Implement kaya-rbcs: simplified RBCS clone on the Kaya framework
CI / Build and push docker image (push) Successful in 2m57s
CI / Build and push docker image (push) Successful in 2m57s
- KayaApp with GET/PUT recursive routes backed by memcached (aiomcache) - env-based configuration, metadata-aware value encoding, key prefix/digest - Granian RSGI launcher (rbcs-server / python -m kaya_rbcs) - pinned requirements via pip-compile, alpine:3.24 Dockerfile with healthcheck - offline tests using an in-process fake memcached - Gitea Actions workflow to build and push the multi-arch Docker image
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""A minimal in-process memcached server (text protocol) for offline tests.
|
||||
|
||||
It implements the subset of the memcached text protocol used by
|
||||
``aiomcache`` (and thus by this project): ``get``, ``set``/``add``/
|
||||
``replace``/``append``/``prepend``, ``touch``, ``delete``, ``version``,
|
||||
``flush_all`` and ``quit``, including lazy expiration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from time import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
_THIRTY_DAYS = 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
class FakeMemcachedServer:
|
||||
def __init__(self) -> None:
|
||||
self._data: Dict[bytes, Tuple[int, bytes]] = {}
|
||||
self._server: Optional[asyncio.AbstractServer] = None
|
||||
self.port: int = 0
|
||||
|
||||
async def start(self) -> None:
|
||||
self._server = await asyncio.start_server(self._handle, '127.0.0.1', 0)
|
||||
assert self._server.sockets is not None
|
||||
self.port = self._server.sockets[0].getsockname()[1]
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
|
||||
@staticmethod
|
||||
def _expiry_timestamp(exptime: int, now: float) -> int:
|
||||
if exptime == 0:
|
||||
return 0
|
||||
if exptime > _THIRTY_DAYS:
|
||||
return exptime
|
||||
return int(now) + exptime
|
||||
|
||||
def _expired(self, key: bytes, now: float) -> bool:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return False
|
||||
expiry, _ = entry
|
||||
return 0 < expiry <= now
|
||||
|
||||
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
parts = line.rstrip(b'\r\n').split()
|
||||
if not parts:
|
||||
continue
|
||||
command = parts[0]
|
||||
if command == b'get':
|
||||
self._handle_get(parts[1:], writer)
|
||||
elif command in (b'set', b'add', b'replace', b'append', b'prepend'):
|
||||
await self._handle_store(command, parts, reader, writer)
|
||||
elif command == b'touch':
|
||||
self._handle_touch(parts, writer)
|
||||
elif command == b'delete':
|
||||
self._handle_delete(parts, writer)
|
||||
elif command == b'version':
|
||||
writer.write(b'VERSION 1.6.23\r\n')
|
||||
elif command == b'flush_all':
|
||||
self._data.clear()
|
||||
writer.write(b'OK\r\n')
|
||||
elif command == b'quit':
|
||||
break
|
||||
else:
|
||||
writer.write(b'ERROR\r\n')
|
||||
await writer.drain()
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
def _handle_get(self, keys: List[bytes], writer: asyncio.StreamWriter) -> None:
|
||||
now = time()
|
||||
for key in keys:
|
||||
if key in self._data and not self._expired(key, now):
|
||||
_, value = self._data[key]
|
||||
writer.write(
|
||||
b'VALUE ' + key + b' 0 ' + str(len(value)).encode('utf-8') + b'\r\n'
|
||||
)
|
||||
writer.write(value + b'\r\n')
|
||||
writer.write(b'END\r\n')
|
||||
|
||||
async def _handle_store(
|
||||
self,
|
||||
command: bytes,
|
||||
parts: List[bytes],
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
key = parts[1]
|
||||
exptime = int(parts[3])
|
||||
length = int(parts[4])
|
||||
value = (await reader.readexactly(length + 2))[:-2]
|
||||
now = time()
|
||||
stored = False
|
||||
if command == b'set':
|
||||
self._data[key] = (self._expiry_timestamp(exptime, now), value)
|
||||
stored = True
|
||||
elif command == b'add':
|
||||
if not (key in self._data and not self._expired(key, now)):
|
||||
self._data[key] = (self._expiry_timestamp(exptime, now), value)
|
||||
stored = True
|
||||
elif command == b'replace':
|
||||
if key in self._data and not self._expired(key, now):
|
||||
self._data[key] = (self._expiry_timestamp(exptime, now), value)
|
||||
stored = True
|
||||
elif command in (b'append', b'prepend'):
|
||||
if key in self._data and not self._expired(key, now):
|
||||
expiry, existing = self._data[key]
|
||||
self._data[key] = (
|
||||
expiry,
|
||||
existing + value if command == b'append' else value + existing,
|
||||
)
|
||||
stored = True
|
||||
writer.write(b'STORED\r\n' if stored else b'NOT_STORED\r\n')
|
||||
|
||||
def _handle_touch(self, parts: List[bytes], writer: asyncio.StreamWriter) -> None:
|
||||
key = parts[1]
|
||||
exptime = int(parts[2])
|
||||
if key in self._data and not self._expired(key, time()):
|
||||
_, value = self._data[key]
|
||||
self._data[key] = (self._expiry_timestamp(exptime, time()), value)
|
||||
writer.write(b'TOUCHED\r\n')
|
||||
else:
|
||||
writer.write(b'NOT_FOUND\r\n')
|
||||
|
||||
def _handle_delete(self, parts: List[bytes], writer: asyncio.StreamWriter) -> None:
|
||||
key = parts[1]
|
||||
if key in self._data:
|
||||
del self._data[key]
|
||||
writer.write(b'DELETED\r\n')
|
||||
else:
|
||||
writer.write(b'NOT_FOUND\r\n')
|
||||
|
||||
@property
|
||||
def data(self) -> Dict[bytes, Tuple[int, bytes]]:
|
||||
return self._data
|
||||
@@ -0,0 +1,208 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from kaya_rbcs.app import create_app, key_from_path
|
||||
from kaya_rbcs.config import Config
|
||||
from kaya_rbcs.store import MemcacheStore
|
||||
|
||||
from fake_memcached import FakeMemcachedServer
|
||||
|
||||
|
||||
class RbcsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self.memcache = FakeMemcachedServer()
|
||||
await self.memcache.start()
|
||||
self.app = create_app(self._config())
|
||||
self.store: MemcacheStore = getattr(self.app, 'store')
|
||||
self.client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url='http://testserver',
|
||||
)
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.client.aclose()
|
||||
await self.store.close()
|
||||
await self.memcache.stop()
|
||||
|
||||
def _config(self, **overrides: Any) -> Config:
|
||||
params: dict[str, Any] = {
|
||||
'host': '127.0.0.1',
|
||||
'port': 8080,
|
||||
'path_prefix': '/',
|
||||
'memcache_host': '127.0.0.1',
|
||||
'memcache_port': self.memcache.port,
|
||||
}
|
||||
params.update(overrides)
|
||||
return Config(**params)
|
||||
|
||||
async def test_put_get_roundtrip(self) -> None:
|
||||
key = 'abc123'
|
||||
value = b'hello world'
|
||||
put = await self.client.put(
|
||||
'/' + key,
|
||||
content=value,
|
||||
headers={'content-type': 'application/octet-stream'},
|
||||
)
|
||||
self.assertEqual(201, put.status_code)
|
||||
self.assertEqual(key, put.text)
|
||||
self.assertEqual('text/plain', put.headers['content-type'])
|
||||
|
||||
get = await self.client.get('/' + key)
|
||||
self.assertEqual(200, get.status_code)
|
||||
self.assertEqual(value, get.content)
|
||||
self.assertEqual('application/octet-stream', get.headers['content-type'])
|
||||
|
||||
async def test_get_missing_key(self) -> None:
|
||||
get = await self.client.get('/does/not/exist')
|
||||
self.assertEqual(404, get.status_code)
|
||||
self.assertEqual(b'', get.content)
|
||||
|
||||
async def test_nested_key(self) -> None:
|
||||
value = b'nested value'
|
||||
put = await self.client.put('/a/b/c', content=value)
|
||||
self.assertEqual(201, put.status_code)
|
||||
get = await self.client.get('/a/b/c')
|
||||
self.assertEqual(200, get.status_code)
|
||||
self.assertEqual(value, get.content)
|
||||
|
||||
async def test_default_content_type(self) -> None:
|
||||
await self.client.put('/no-type', content=b'x')
|
||||
get = await self.client.get('/no-type')
|
||||
self.assertEqual('application/octet-stream', get.headers['content-type'])
|
||||
|
||||
async def test_content_disposition_roundtrip(self) -> None:
|
||||
disposition = 'inline; filename="page.html"'
|
||||
await self.client.put(
|
||||
'/page',
|
||||
content=b'<html></html>',
|
||||
headers={'content-type': 'text/html', 'content-disposition': disposition},
|
||||
)
|
||||
get = await self.client.get('/page')
|
||||
self.assertEqual(200, get.status_code)
|
||||
self.assertEqual('text/html', get.headers['content-type'])
|
||||
self.assertEqual(disposition, get.headers['content-disposition'])
|
||||
|
||||
async def test_key_prefix_appended(self) -> None:
|
||||
app = create_app(self._config(key_prefix='suffix'))
|
||||
store: MemcacheStore = getattr(app, 'store')
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url='http://testserver',
|
||||
)
|
||||
try:
|
||||
put = await client.put('/key1', content=b'x')
|
||||
self.assertEqual(201, put.status_code)
|
||||
self.assertIn(b'key1suffix', self.memcache.data)
|
||||
|
||||
get = await client.get('/key1')
|
||||
self.assertEqual(200, get.status_code)
|
||||
self.assertEqual(b'x', get.content)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await store.close()
|
||||
|
||||
async def test_digest_hashes_key(self) -> None:
|
||||
app = create_app(self._config(digest='sha256'))
|
||||
store: MemcacheStore = getattr(app, 'store')
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url='http://testserver',
|
||||
)
|
||||
try:
|
||||
put = await client.put('/key1', content=b'x')
|
||||
self.assertEqual(201, put.status_code)
|
||||
expected = hashlib.sha256(b'key1').hexdigest().encode('utf-8')
|
||||
self.assertIn(expected, self.memcache.data)
|
||||
|
||||
get = await client.get('/key1')
|
||||
self.assertEqual(200, get.status_code)
|
||||
self.assertEqual(b'x', get.content)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await store.close()
|
||||
|
||||
async def test_path_prefix(self) -> None:
|
||||
app = create_app(self._config(path_prefix='/cache'))
|
||||
store: MemcacheStore = getattr(app, 'store')
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url='http://testserver',
|
||||
)
|
||||
try:
|
||||
await client.put('/cache/entry', content=b'value')
|
||||
get = await client.get('/cache/entry')
|
||||
self.assertEqual(200, get.status_code)
|
||||
self.assertEqual(b'value', get.content)
|
||||
self.assertIn(b'entry', self.memcache.data)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await store.close()
|
||||
|
||||
def test_key_from_path_rejects_escaping(self) -> None:
|
||||
self.assertIsNone(key_from_path('/cache/../../etc/passwd', '/cache'))
|
||||
self.assertIsNone(key_from_path('/cache/../', '/cache'))
|
||||
self.assertIsNone(key_from_path('/', '/'))
|
||||
self.assertEqual('a/b', key_from_path('/cache/a/b', '/cache'))
|
||||
self.assertEqual('foo', key_from_path('/foo', '/'))
|
||||
self.assertEqual('foo', key_from_path('/cache/foo', '/cache'))
|
||||
|
||||
async def test_escaped_path_is_rejected(self) -> None:
|
||||
# ASGI servers (and httpx) normalize dot segments in the request path
|
||||
# before it reaches the app, so the route is never matched. Either way
|
||||
# the request is rejected; a raw, unnormalized escaping path would be
|
||||
# handled by key_from_path and rejected with 400.
|
||||
app = create_app(self._config(path_prefix='/cache'))
|
||||
store: MemcacheStore = getattr(app, 'store')
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url='http://testserver',
|
||||
)
|
||||
try:
|
||||
get = await client.get('/cache/../../etc/passwd')
|
||||
self.assertIn(get.status_code, (400, 404))
|
||||
put = await client.put('/cache/../../etc/passwd', content=b'x')
|
||||
self.assertIn(put.status_code, (400, 404))
|
||||
finally:
|
||||
await client.aclose()
|
||||
await store.close()
|
||||
|
||||
async def test_max_age_expiry(self) -> None:
|
||||
app = create_app(self._config(max_age=timedelta(seconds=1)))
|
||||
store: MemcacheStore = getattr(app, 'store')
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url='http://testserver',
|
||||
)
|
||||
try:
|
||||
put = await client.put('/ttl', content=b'x')
|
||||
self.assertEqual(201, put.status_code)
|
||||
|
||||
get = await client.get('/ttl')
|
||||
self.assertEqual(200, get.status_code)
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
get = await client.get('/ttl')
|
||||
self.assertEqual(404, get.status_code)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await store.close()
|
||||
|
||||
async def test_overwrite_value(self) -> None:
|
||||
await self.client.put('/same', content=b'first')
|
||||
await self.client.put('/same', content=b'second')
|
||||
get = await self.client.get('/same')
|
||||
self.assertEqual(200, get.status_code)
|
||||
self.assertEqual(b'second', get.content)
|
||||
|
||||
async def test_invalid_method_returns_404(self) -> None:
|
||||
post = await self.client.post('/anything', content=b'x')
|
||||
self.assertEqual(404, post.status_code)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user