Skip to content

Commit fc4a119

Browse files
Request format of file contents using headers (#80)
* feat: get_config endpoint uses accept header to format response * feat: Add OpenAPI schema to endpoint --------- Co-authored-by: Joseph Ware <[email protected]>
1 parent 48cb34b commit fc4a119

File tree

12 files changed

+460
-150
lines changed

12 files changed

+460
-150
lines changed

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ RUN python -m venv /venv
1313
ENV PATH=/venv/bin:$PATH
1414

1515
# The build stage installs the context into the venv
16-
FROM developer as build
16+
FROM developer AS build
1717
COPY . /context
1818
WORKDIR /context
1919
RUN pip install .[server]
2020

2121
# The runtime stage copies the built venv into a slim runtime container
22-
FROM python:${PYTHON_VERSION}-slim as runtime
22+
FROM python:${PYTHON_VERSION}-slim AS runtime
2323
RUN apt-get update && apt-get install -y --no-install-recommends \
2424
curl \
2525
procps \

pyproject.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,6 @@ name = "Oliver Silvester"
5757
[tool.setuptools_scm]
5858
version_file = "src/daq_config_server/_version.py"
5959

60-
[tool.pyright]
61-
typeCheckingMode = "strict"
62-
reportMissingImports = false # Ignore missing stubs in imported modules
63-
6460
[tool.pytest.ini_options]
6561
asyncio_mode = "auto"
6662
asyncio_default_fixture_loop_scope = "function"

pyrightconfig.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"typeCheckingMode": "strict",
3+
"reportMissingImports": false,
4+
"reportPrivateUsage": true,
5+
"executionEnvironments": [
6+
{
7+
"root": "./tests",
8+
"reportPrivateUsage": false
9+
}
10+
],
11+
"reportMissingTypeStubs": false,
12+
"extraPaths": ["."]
13+
}

src/daq_config_server/app.py

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import json
2+
import os
3+
from enum import StrEnum
14
from pathlib import Path
25

36
import uvicorn
4-
from fastapi import FastAPI
7+
from fastapi import FastAPI, HTTPException, Request
58
from fastapi.middleware.cors import CORSMiddleware
9+
from fastapi.responses import JSONResponse, Response
610

711
from daq_config_server.constants import ENDPOINTS
12+
from daq_config_server.log import LOGGER
813

914
app = FastAPI(
1015
title="DAQ config server",
@@ -22,17 +27,79 @@
2227
__all__ = ["main"]
2328

2429

25-
@app.get(ENDPOINTS.CONFIG + "/{file_path:path}")
26-
def get_configuration(file_path: Path):
27-
"""Read a file and return its contents completely unformatted as a string. After
28-
https://github.com/DiamondLightSource/daq-config-server/issues/67, this endpoint
29-
will convert commonly read files to a dictionary format
30+
class ValidAcceptHeaders(StrEnum):
31+
JSON = "application/json"
32+
PLAIN_TEXT = "text/plain"
33+
RAW_BYTES = "application/octet-stream"
34+
35+
36+
@app.get(
37+
ENDPOINTS.CONFIG + "/{file_path:path}",
38+
responses={
39+
200: {
40+
"description": "Returns JSON, plain text, or binary file.",
41+
"content": {
42+
"application/json": {
43+
"schema": {
44+
"type": "object",
45+
"additionalProperties": True,
46+
"example": {
47+
"key": "value",
48+
"list": [1, 2, 3],
49+
"nested": {"a": 1},
50+
},
51+
}
52+
},
53+
"text/plain": {
54+
"schema": {
55+
"type": "string",
56+
"example": "This is a plain text response",
57+
}
58+
},
59+
"application/octet-stream": {
60+
"schema": {"type": "string", "format": "binary"},
61+
},
62+
},
63+
},
64+
},
65+
)
66+
def get_configuration(
67+
file_path: Path,
68+
request: Request,
69+
):
70+
"""
71+
Read a file and return its contents in a format specified by the accept header.
3072
"""
3173
if not file_path.is_file():
32-
raise FileNotFoundError(f"File {file_path} cannot be found")
74+
raise HTTPException(status_code=404, detail=f"File {file_path} cannot be found")
75+
76+
file_name = os.path.basename(file_path)
77+
accept = request.headers.get("accept", ValidAcceptHeaders.PLAIN_TEXT)
78+
79+
try:
80+
match accept:
81+
case ValidAcceptHeaders.JSON:
82+
with file_path.open("r", encoding="utf-8") as f:
83+
content = json.loads(f.read())
84+
return JSONResponse(
85+
content=content,
86+
)
87+
case ValidAcceptHeaders.PLAIN_TEXT:
88+
with file_path.open("r", encoding="utf-8") as f:
89+
content = f.read()
90+
return Response(content=content, media_type=accept)
91+
case _:
92+
pass
93+
except Exception as e:
94+
LOGGER.warning(
95+
f"Failed to convert {file_name} to {accept} and caught \
96+
exception: {e} \nSending file as raw bytes instead"
97+
)
98+
99+
with file_path.open("rb") as f:
100+
content = f.read()
33101

34-
with file_path.open("r", encoding="utf-8") as f:
35-
return f.read()
102+
return Response(content=content, media_type=ValidAcceptHeaders.RAW_BYTES)
36103

37104

38105
def main():

src/daq_config_server/client.py

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import operator
2+
from enum import StrEnum
23
from logging import Logger, getLogger
4+
from pathlib import Path
35
from typing import Any
46

57
import requests
68
from cachetools import TTLCache, cachedmethod
9+
from requests import Response
10+
11+
from daq_config_server.app import ValidAcceptHeaders
712

813
from .constants import ENDPOINTS
914

1015

16+
class RequestedResponseFormats(StrEnum):
17+
DICT = ValidAcceptHeaders.JSON # Convert to dict using Response.json()
18+
DECODED_STRING = ValidAcceptHeaders.PLAIN_TEXT # Use utf-8 decoding in response
19+
RAW_BYTE_STRING = ValidAcceptHeaders.RAW_BYTES # Use raw bytes in response
20+
21+
1122
class ConfigServer:
1223
def __init__(
1324
self,
@@ -27,77 +38,105 @@ def __init__(
2738
"""
2839
self._url = url.rstrip("/")
2940
self._log = log if log else getLogger("daq_config_server.client")
30-
self._cache: TTLCache[tuple[str, str | None], str] = TTLCache(
41+
self._cache: TTLCache[tuple[str, str, Path], str] = TTLCache(
3142
maxsize=cache_size, ttl=cache_lifetime_s
3243
)
3344

34-
def _get(
35-
self,
36-
endpoint: str,
37-
item: str | None = None,
38-
reset_cached_result: bool = False,
39-
) -> Any:
40-
"""
41-
Get data from the config server with cache management.
42-
If a cached response doesn't already exist, makes a request to
43-
the config server.
44-
If reset_cached_result is true, remove the cache entry for that request and
45-
make a new request
46-
47-
Args:
48-
endpoint: API endpoint.
49-
item: Optional item identifier.
50-
reset_cached_result: Whether to reset cache.
51-
52-
Returns:
53-
The response data.
54-
"""
55-
56-
if (endpoint, item) in self._cache and reset_cached_result:
57-
del self._cache[(endpoint, item)]
58-
return self._cached_get(endpoint, item)
59-
6045
@cachedmethod(cache=operator.attrgetter("_cache"))
6146
def _cached_get(
6247
self,
6348
endpoint: str,
64-
item: str | None = None,
65-
) -> Any:
49+
accept_header: str,
50+
file_path: Path,
51+
) -> Response:
6652
"""
6753
Get data from the config server and cache it.
6854
6955
Args:
7056
endpoint: API endpoint.
71-
item: Optional item identifier.
57+
file_path: absolute path to the file which will be read
7258
7359
Returns:
7460
The response data.
7561
"""
76-
url = self._url + endpoint + (f"/{item}" if item else "")
7762

7863
try:
79-
r = requests.get(url)
64+
request_url = self._url + endpoint + (f"/{file_path}")
65+
r = requests.get(request_url, headers={"Accept": accept_header})
8066
r.raise_for_status()
81-
data = r.json()
82-
self._log.debug(f"Cache set for {endpoint}/{item}.")
83-
return data
67+
self._log.debug(f"Cache set for {request_url}.")
68+
return r
8469
except requests.exceptions.HTTPError as e:
8570
self._log.error(f"HTTP error: {e}")
8671
raise
8772

88-
def read_unformatted_file(
89-
self, file_path: str, reset_cached_result: bool = False
73+
def _get(
74+
self,
75+
endpoint: str,
76+
accept_header: str,
77+
file_path: Path,
78+
reset_cached_result: bool = False,
79+
):
80+
"""
81+
Get data from the config server with cache management and use
82+
the content-type response header to format the return value.
83+
If data parsing fails, return the response contents in bytes
84+
"""
85+
if (endpoint, accept_header, file_path) in self._cache and reset_cached_result:
86+
del self._cache[(endpoint, accept_header, file_path)]
87+
r = self._cached_get(endpoint, accept_header, file_path)
88+
89+
content_type = r.headers["content-type"].split(";")[0].strip()
90+
91+
if content_type != accept_header:
92+
self._log.warning(
93+
f"Server failed to parse the file as requested. Requested \
94+
{accept_header} but response came as content-type {content_type}"
95+
)
96+
97+
try:
98+
match content_type:
99+
case ValidAcceptHeaders.JSON:
100+
content = r.json()
101+
case ValidAcceptHeaders.PLAIN_TEXT:
102+
content = r.text
103+
case _:
104+
content = r.content
105+
except Exception as e:
106+
self._log.warning(
107+
f"Failed trying to convert to content-type {content_type} due to\
108+
exception {e} \nReturning as bytes instead"
109+
)
110+
content = r.content
111+
112+
return content
113+
114+
def get_file_contents(
115+
self,
116+
file_path: Path,
117+
requested_response_format: RequestedResponseFormats = (
118+
RequestedResponseFormats.DECODED_STRING
119+
),
120+
reset_cached_result: bool = False,
90121
) -> Any:
91122
"""
92-
Read an unformatted file from the config server.
123+
Get contents of a file from the config server in the format specified.
124+
If data parsing fails, contents will return as raw bytes. Optionally look
125+
for cached result before making request.
93126
94127
Args:
95128
file_path: Path to the file.
96-
reset_cached_result: Whether to reset cache.
97-
129+
requested_response_format: Specify how to parse the response.
130+
reset_cached_result: If true, make a request and store response in cache,
131+
otherwise look for cached response before making
132+
new request
98133
Returns:
99-
The file content.
134+
The file contents, in the format specified.
100135
"""
136+
101137
return self._get(
102-
ENDPOINTS.CONFIG, file_path, reset_cached_result=reset_cached_result
138+
ENDPOINTS.CONFIG,
139+
requested_response_format,
140+
file_path,
141+
reset_cached_result=reset_cached_result,
103142
)

src/daq_config_server/log.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import logging
2+
3+
4+
def get_default_logger(name: str = __name__) -> logging.Logger:
5+
logger = logging.getLogger(name)
6+
logger.setLevel(logging.INFO)
7+
8+
# Prevent adding handlers multiple times
9+
if not logger.handlers:
10+
console_handler = logging.StreamHandler()
11+
formatter = logging.Formatter(
12+
"%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
13+
)
14+
console_handler.setFormatter(formatter)
15+
logger.addHandler(console_handler)
16+
17+
return logger
18+
19+
20+
# For now use a basic console-writing logger. Integrate properly with kubernetes in the
21+
# future
22+
LOGGER = get_default_logger("daq-config-server")

tests/constants.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
from pathlib import Path
22

3-
TEST_DATA_DIR = Path(f"{Path(__file__).parent}/test_data")
3+
TEST_DATA_DIR_PATH = Path(f"{Path(__file__).parent}/test_data")
4+
5+
TEST_BEAMLINE_PARAMETERS_PATH = TEST_DATA_DIR_PATH.joinpath("beamline_parameters.txt")
6+
7+
TEST_BAD_JSON_PATH = TEST_DATA_DIR_PATH.joinpath("test_bad_json")
8+
9+
TEST_GOOD_JSON_PATH = TEST_DATA_DIR_PATH.joinpath("test_good_json.json")

0 commit comments

Comments
 (0)