Skip to content

Commit 3cfc44c

Browse files
authored
feat: add fastapi + service_info endpoint (#78)
* FastAPI api.py module, including a /service_info endpoint conforming to the GA4GH spec. I didn't bother implementing updatedAt (it's optional) because I think it'd be kind of a hassle to update a datetime object every time we make a release. Maybe there's a way to automate this but not a priority IMO. * An API tests module with reasonable coverage * A library config module. I don't think we have any services right now that quite map to the dev/test/staging/prod schema (I took this from rails), but some are pretty close and we could tweak as needed. The idea here is that it's a central source of truth about what kind of an environment the app is running in, and then other modules (like a database module or the main API views module) refer to it when making environment-based decisions. The individual settings here (test and debug) are sort of general/predictive of what we might need in a given app. Lots of examples online also set a DB URL or DB connection configs here as well. (I also looked at pydantic-settings for this but I think it's a lot more than we need. ) * A logging setup module. This would also be shared with a CLI module (which is my next project for this repo). * Add FAST rules from ruff.
1 parent c948431 commit 3cfc44c

File tree

11 files changed

+308
-4
lines changed

11 files changed

+308
-4
lines changed

python/cookiecutter.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
"repo": "{{ cookiecutter.project_slug }}",
77
"your_name": "",
88
"your_email": "",
9-
"add_docs": [false, true]
9+
"add_docs": [false, true],
10+
"add_fastapi": [false, true]
1011
}

python/hooks/post_gen_project.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
"""Provide hooks to run after project is generated."""
2+
23
from pathlib import Path
34
import shutil
45

56

67
if not {{ cookiecutter.add_docs }}:
78
shutil.rmtree("docs")
89
Path(".readthedocs.yaml").unlink()
10+
11+
12+
if not {{ cookiecutter.add_fastapi }}:
13+
Path("tests/test_api.py").unlink()
14+
Path("src/{{ cookiecutter.project_slug }}/api.py").unlink()
15+
Path("src/{{ cookiecutter.project_slug }}/models.py").unlink()
16+
Path("src/{{ cookiecutter.project_slug }}/config.py").unlink()
17+
Path("src/{{ cookiecutter.project_slug }}/logging.py").unlink()

python/{{cookiecutter.project_slug}}/pyproject.toml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,29 @@ classifiers = [
1818
requires-python = ">=3.11"
1919
description = "{{ cookiecutter.description }}"
2020
license = {file = "LICENSE"}
21+
{%- if cookiecutter.add_fastapi %}
22+
dependencies = [
23+
"fastapi",
24+
"pydantic~=2.1",
25+
]
26+
{% else %}
2127
dependencies = []
28+
{% endif -%}
2229
dynamic = ["version"]
2330

2431
[project.optional-dependencies]
25-
tests = ["pytest", "pytest-cov"]
32+
tests = [
33+
"pytest",
34+
"pytest-cov",
35+
{%- if cookiecutter.add_fastapi %}
36+
"httpx",
37+
{%- endif %}
38+
]
2639
dev = [
2740
"pre-commit>=4.0.1",
2841
"ruff==0.8.6",
2942
]
30-
{% if cookiecutter.add_docs %}
43+
{%- if cookiecutter.add_docs %}
3144
docs = [
3245
"sphinx==6.1.3",
3346
"sphinx-autodoc-typehints==1.22.0",
@@ -100,6 +113,9 @@ select = [
100113
"ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
101114
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
102115
"PGH", # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh
116+
{%- if cookiecutter.add_fastapi %}
117+
"FAST", # https://docs.astral.sh/ruff/rules/#fastapi-fast
118+
{%- endif %}
103119
"PLC", # https://docs.astral.sh/ruff/rules/#convention-c
104120
"PLE", # https://docs.astral.sh/ruff/rules/#error-e_1
105121
"TRY", # https://docs.astral.sh/ruff/rules/#tryceratops-try
@@ -121,6 +137,9 @@ fixable = [
121137
"PT",
122138
"RSE",
123139
"SIM",
140+
{%- if cookiecutter.add_fastapi %}
141+
"FAST",
142+
{%- endif %}
124143
"PLC",
125144
"PLE",
126145
"TRY",

python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""{{ cookiecutter.description }}"""
2+
23
from importlib.metadata import PackageNotFoundError, version
34

45

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Define API endpoints."""
2+
3+
from collections.abc import AsyncGenerator
4+
from contextlib import asynccontextmanager
5+
from enum import Enum
6+
7+
from fastapi import FastAPI
8+
9+
from {{ cookiecutter.project_slug }} import __version__
10+
from {{ cookiecutter.project_slug }}.config import config
11+
from {{ cookiecutter.project_slug }}.logging import initialize_logs
12+
from {{ cookiecutter.project_slug }}.models import ServiceInfo, ServiceOrganization, ServiceType
13+
14+
15+
@asynccontextmanager
16+
async def lifespan(app: FastAPI) -> AsyncGenerator: # noqa: ARG001
17+
"""Perform operations that interact with the lifespan of the FastAPI instance.
18+
19+
See https://fastapi.tiangolo.com/advanced/events/#lifespan.
20+
21+
:param app: FastAPI instance
22+
"""
23+
initialize_logs()
24+
yield
25+
26+
27+
class _Tag(str, Enum):
28+
"""Define tag names for endpoints."""
29+
30+
META = "Meta"
31+
32+
33+
app = FastAPI(
34+
title="{{ cookiecutter.project_slug }}",
35+
description="{{ cookiecutter.description }}",
36+
version=__version__,
37+
contact={
38+
"name": "Alex H. Wagner",
39+
"email": "[email protected]",
40+
"url": "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab",
41+
},
42+
license={
43+
"name": "MIT",
44+
"url": "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}/blob/main/LICENSE",
45+
},
46+
docs_url="/docs",
47+
openapi_url="/openapi.json",
48+
swagger_ui_parameters={"tryItOutEnabled": True},
49+
)
50+
51+
52+
@app.get(
53+
"/service_info",
54+
summary="Get basic service information",
55+
description="Retrieve service metadata, such as versioning and contact info. Structured in conformance with the [GA4GH service info API specification](https://www.ga4gh.org/product/service-info/)",
56+
tags=[_Tag.META],
57+
)
58+
def service_info() -> ServiceInfo:
59+
"""Provide service info per GA4GH Service Info spec
60+
61+
:return: conformant service info description
62+
"""
63+
return ServiceInfo(
64+
organization=ServiceOrganization(), type=ServiceType(), environment=config.env
65+
)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Read and provide runtime configuration."""
2+
3+
import logging
4+
import os
5+
6+
from pydantic import BaseModel
7+
8+
from {{ cookiecutter.project_slug }}.models import ServiceEnvironment
9+
10+
11+
_logger = logging.getLogger(__name__)
12+
13+
14+
_ENV_VARNAME = "{{ cookiecutter.project_slug | upper }}_ENV"
15+
16+
17+
class Config(BaseModel):
18+
"""Define app configuration data object."""
19+
20+
env: ServiceEnvironment
21+
debug: bool
22+
test: bool
23+
24+
25+
def _dev_config() -> Config:
26+
"""Provide development environment configs
27+
28+
:return: dev env configs
29+
"""
30+
return Config(env=ServiceEnvironment.DEV, debug=True, test=False)
31+
32+
33+
def _test_config() -> Config:
34+
"""Provide test env configs
35+
36+
:return: test configs
37+
"""
38+
return Config(env=ServiceEnvironment.TEST, debug=False, test=True)
39+
40+
41+
def _staging_config() -> Config:
42+
"""Provide staging env configs
43+
44+
:return: staging configs
45+
"""
46+
return Config(env=ServiceEnvironment.STAGING, debug=False, test=False)
47+
48+
49+
def _prod_config() -> Config:
50+
"""Provide production configs
51+
52+
:return: prod configs
53+
"""
54+
return Config(env=ServiceEnvironment.PROD, debug=False, test=False)
55+
56+
57+
def _default_config() -> Config:
58+
"""Provide default configs. This function sets what they are.
59+
60+
:return: default configs
61+
"""
62+
return _dev_config()
63+
64+
65+
_CONFIG_MAP = {
66+
ServiceEnvironment.DEV: _dev_config,
67+
ServiceEnvironment.TEST: _test_config,
68+
ServiceEnvironment.STAGING: _staging_config,
69+
ServiceEnvironment.PROD: _prod_config,
70+
}
71+
72+
73+
def _set_config() -> Config:
74+
"""Set configs based on environment variable `{{ cookiecutter.project_slug | upper }}_ENV`.
75+
76+
:return: complete config object with environment-specific parameters
77+
"""
78+
raw_env_value = os.environ.get(_ENV_VARNAME)
79+
if not raw_env_value:
80+
return _default_config()
81+
try:
82+
env_value = ServiceEnvironment(raw_env_value.lower())
83+
except ValueError:
84+
_logger.error(
85+
"Unrecognized value for %s: '%s'. Using default configs",
86+
_ENV_VARNAME,
87+
raw_env_value
88+
)
89+
return _default_config()
90+
return _CONFIG_MAP[env_value]()
91+
92+
93+
config = _set_config()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Configure application logging."""
2+
3+
import logging
4+
5+
6+
def initialize_logs(log_level: int = logging.DEBUG) -> None:
7+
"""Configure logging.
8+
9+
:param log_level: app log level to set
10+
"""
11+
logging.basicConfig(
12+
filename=f"{__package__}.log",
13+
format="[%(asctime)s] - %(name)s - %(levelname)s : %(message)s",
14+
)
15+
logger = logging.getLogger(__package__)
16+
logger.setLevel(log_level)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Define models for internal data and API responses."""
2+
3+
from enum import Enum
4+
from typing import Literal
5+
6+
from pydantic import BaseModel
7+
8+
from . import __version__
9+
10+
11+
class ServiceEnvironment(str, Enum):
12+
"""Define current runtime environment."""
13+
14+
DEV = "dev"
15+
PROD = "prod"
16+
TEST = "test"
17+
STAGING = "staging"
18+
19+
20+
class ServiceOrganization(BaseModel):
21+
"""Define service_info response for organization field"""
22+
23+
name: Literal["Genomic Medicine Lab at Nationwide Children's Hospital"] = (
24+
"Genomic Medicine Lab at Nationwide Children's Hospital"
25+
)
26+
url: Literal[
27+
"https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab"
28+
] = "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab"
29+
30+
31+
class ServiceType(BaseModel):
32+
"""Define service_info response for type field"""
33+
34+
group: Literal["org.genomicmedlab"] = "org.genomicmedlab"
35+
artifact: Literal["{{ cookiecutter.project_slug }} API"] = "{{ cookiecutter.project_slug }} API"
36+
version: Literal[__version__] = __version__
37+
38+
39+
class ServiceInfo(BaseModel):
40+
"""Define response structure for GA4GH /service_info endpoint."""
41+
42+
id: Literal["org.genomicmedlab.{{ cookiecutter.project_slug }}"] = (
43+
"org.genomicmedlab.{{ cookiecutter.project_slug }}"
44+
)
45+
name: Literal["{{ cookiecutter.project_slug }}"] = "{{ cookiecutter.project_slug }}"
46+
type: ServiceType
47+
description: Literal["{{ cookiecutter.description }}"] = "{{ cookiecutter.description }}"
48+
organization: ServiceOrganization
49+
contactUrl: Literal["[email protected]"] = ( # noqa: N815
50+
51+
)
52+
documentationUrl: Literal["https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}"] = ( # noqa: N815
53+
"https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}"
54+
)
55+
createdAt: Literal["{% now 'utc', '%Y-%m-%dT%H:%M:%S+00:00' %}"] = "{% now 'utc', '%Y-%m-%dT%H:%M:%S+00:00' %}" # noqa: N815
56+
updatedAt: str | None = None # noqa: N815
57+
environment: ServiceEnvironment
58+
version: Literal[__version__] = __version__

python/{{cookiecutter.project_slug}}/tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
def pytest_addoption(parser):
22
"""Add custom commands to pytest invocation.
33
4-
See https://docs.pytest.org/en/8.1.x/reference/reference.html#parser"""
4+
See https://docs.pytest.org/en/8.1.x/reference/reference.html#parser
5+
"""
56
parser.addoption(
67
"--verbose-logs",
78
action="store_true",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Test FastAPI endpoint function."""
2+
3+
from datetime import datetime
4+
import re
5+
6+
import pytest
7+
from fastapi.testclient import TestClient
8+
9+
from {{ cookiecutter.project_slug }}.api import app
10+
from {{ cookiecutter.project_slug }}.models import ServiceEnvironment
11+
12+
13+
@pytest.fixture(scope="module")
14+
def api_client():
15+
return TestClient(app)
16+
17+
18+
def test_service_info(api_client: TestClient):
19+
response = api_client.get("/service_info")
20+
assert response.status_code == 200
21+
expected_version_pattern = r"\d\.\d\." # at minimum, should be something like "0.1"
22+
response_json = response.json()
23+
assert response_json["id"] == "org.genomicmedlab.{{ cookiecutter.project_slug }}"
24+
assert response_json["name"] == "{{ cookiecutter.project_slug }}"
25+
assert response_json["type"]["group"] == "org.genomicmedlab"
26+
assert response_json["type"]["artifact"] == "{{ cookiecutter.project_slug }} API"
27+
assert re.match(expected_version_pattern, response_json["type"]["version"])
28+
assert response_json["description"] == "{{ cookiecutter.description }}"
29+
assert response_json["organization"] == {
30+
"name": "Genomic Medicine Lab at Nationwide Children's Hospital",
31+
"url": "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab",
32+
}
33+
assert response_json["contactUrl"] == "[email protected]"
34+
assert (
35+
response_json["documentationUrl"]
36+
== "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}"
37+
)
38+
assert datetime.fromisoformat(response_json["createdAt"])
39+
assert ServiceEnvironment(response_json["environment"])
40+
assert re.match(expected_version_pattern, response_json["version"])

0 commit comments

Comments
 (0)