Skip to content

Commit

Permalink
Merge pull request #81 from databio/drs
Browse files Browse the repository at this point in the history
Drs
  • Loading branch information
nsheff authored Nov 9, 2023
2 parents b4112a6 + 89519dd commit 5de19f9
Show file tree
Hide file tree
Showing 10 changed files with 363 additions and 278 deletions.
3 changes: 3 additions & 0 deletions bedhost/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import logging
import logmuse

from .const import PKG_NAME

_LOGGER = logmuse.init_logger(PKG_NAME)

logging.getLogger("bbconf").setLevel(logging.DEBUG)
4 changes: 2 additions & 2 deletions bedhost/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@

# for now bedstat version is hard coded
ALL_VERSIONS = {
"apiserver_version": SERVER_VERSION,
"bedhost_version": SERVER_VERSION,
"bbconf_version": bbconf_v,
"python_version": python_version(),
}
TEMPLATES_DIRNAME = "templates"
TEMPLATES_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), TEMPLATES_DIRNAME
)
STATIC_DIRNAME = "static"
STATIC_DIRNAME = "../docs"
STATIC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), STATIC_DIRNAME)

UI_PATH = os.path.join(os.path.dirname(__file__), "static", "bedhost-ui")
Expand Down
55 changes: 7 additions & 48 deletions bedhost/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from bbconf import BedBaseConf
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse, RedirectResponse
from starlette.responses import FileResponse, RedirectResponse, JSONResponse
from typing import List, Union
from urllib import parse

Expand Down Expand Up @@ -34,7 +34,7 @@ def serve_file(self, path: str, remote: bool = None):
:param bool remote: whether to redirect to a remote source or serve local
:exception FileNotFoundError: if file not found
"""
remote = remote or self.is_remote
remote = remote or True
if remote:
_LOGGER.info(f"Redirecting to: {path}")
return RedirectResponse(path)
Expand Down Expand Up @@ -208,7 +208,7 @@ def attach_routers(app):
return app


def configure(bbconf_file_path):
def configure(bbconf_file_path, app):
try:
# bbconf_file_path = os.environ.get("BEDBASE_CONFIG") or None
_LOGGER.info(f"Loading config: '{bbconf_file_path}'")
Expand Down Expand Up @@ -238,48 +238,7 @@ def configure(bbconf_file_path):
return bbc


# def get_id_map(bbc, table_name, file_type):
# """
# Get a dict for avalible file/figure ids
#
# :param str table_name: table name to query
# :param st file_type: "file" or "image"
# :return dict
# """
#
# id_map = {}
#
# schema = serve_schema_for_table(bbc=bbc, table_name=table_name)
# # This is basically just doing this:
# # if table_name == BED_TABLE:
# # schema = bbc.bed.schema
# # if table_name == BEDSET_TABLE:
# # schema = bbc.bedset.schema
# # TODO: Eliminate the need for bedhost to be aware of table names; this should be abstracted away by bbconf/pipestat
# for key, value in schema.sample_level_data.items():
# if value["type"] == file_type:
# id_map[value["label"]] = key
#
# return id_map


# def get_enum_map(bbc, table_name, file_type):
# """
# Get a dict of file/figure labels

# :param str table_name: table name to query
# :param st file_type: "file" or "image"
# :return dict
# """

# enum_map = {}
# _LOGGER.debug(f"Getting enum map for {file_type} in {table_name}")

# # TO FIX: I think we need a different way to get the schema
# schema = serve_schema_for_table(bbc=bbc, table_name=table_name)

# for key, value in schema.sample_level_data.items():
# if value["type"] == file_type:
# enum_map[value["label"]] = value["label"]

# return enum_map
def drs_response(status_code, msg):
"""Helper function to make quick DRS responses"""
content = {"status_code": status_code, "msg": msg}
return JSONResponse(status_code=status_code, content=content)
225 changes: 211 additions & 14 deletions bedhost/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@
import sys
import uvicorn

from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, HTMLResponse
from typing import Dict
from urllib.parse import urlparse
from fastapi import Response, HTTPException

from bbconf.exceptions import *
from pipestat.exceptions import RecordNotFoundError, ColumnNotFoundError


from . import _LOGGER
from .helpers import FileResponse, configure, attach_routers, get_openapi_version
from .helpers import (
FileResponse,
configure,
attach_routers,
get_openapi_version,
drs_response,
)
from .cli import build_parser
from .const import (
ALL_VERSIONS,
Expand All @@ -19,11 +32,36 @@
SERVER_VERSION,
)

tags_metadata = [
{
"name": "home",
"description": "General landing page and service info",
},
{
"name": "objects",
"description": "Download BED files or BEDSET files via [GA4GH DRS standard](https://ga4gh.github.io/data-repository-service-schemas/). For details, see [BEDbase Developer Guide](/docs/guide).",

},
{
"name": "bed",
"description": "Endpoints for retrieving metadata for BED records",
},
{
"name": "bedset",
"description": "Endpoints for retrieving metadata for BEDSET records",
},
{
"name": "search",
"description": "Discovery-oriented endpoints for finding records of interest",
},
]

app = FastAPI(
title=PKG_NAME,
description="BED file/sets statistics and image server API",
version=SERVER_VERSION,
docs_url="/docs",
openapi_tags=tags_metadata,
)

origins = [
Expand All @@ -42,23 +80,182 @@
allow_headers=["*"],
)

import markdown
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="bedhost/templates", autoescape=False)

@app.get("/", summary="API intro page", tags=["home"])
async def index(request: Request):
"""
Display the index UI page
"""
return render_markdown("index.md", request)


@app.get("/docs/changelog", summary="Release notes", response_class=HTMLResponse, tags=["home"])
async def changelog(request: Request):
return render_markdown("changelog.md", request)

@app.get("/docs/guide", summary="Developer guide", response_class=HTMLResponse, tags=["home"])
async def guide(request: Request):
return render_markdown("guide.md", request)

def render_markdown(filename: str, request: Request):
with open(os.path.join(STATIC_PATH, filename), "r", encoding="utf-8") as input_file:
text = input_file.read()
content = markdown.markdown(text)
return templates.TemplateResponse("page.html", {"request": request, "content": content})


@app.get("/service-info", summary="GA4GH service info", tags=["home"])
async def service_info():
"""
Returns information about this service, such as versions, name, etc.
"""
all_versions = ALL_VERSIONS
service_version = all_versions["bedhost_version"]
all_versions.update({"openapi_version": get_openapi_version(app)})
ret = {
"id": "org.bedbase.api",
"name": "BEDbase API",
"type": {
"group": "org.databio",
"artifact": "bedbase",
"version": service_version,
},
"description": "An API providing genomic interval data and metadata",
"organization": {"name": "Databio Lab", "url": "https://databio.org"},
"contactUrl": "https://github.com/databio/bedbase/issues",
"documentationUrl": "https://bedbase.org",
"updatedAt": "2023-10-25T00:00:00Z",
"environment": "dev",
"version": service_version,
"component_versions": all_versions,
}
return JSONResponse(content=ret)

@app.get(
"/objects/{object_id}",
summary="Get DRS object metadata",
tags=["objects"],
)
async def get_drs_object_metadata(object_id: str, req: Request):
"""
Returns metadata about a DrsObject.
"""
ids = parse_bedbase_drs_object_id(object_id)
base_uri = urlparse(str(req.url)).netloc
return bbc.get_drs_metadata(
ids["record_type"], ids["record_id"], ids["result_id"], base_uri
)


@app.get(
"/objects/{object_id}/access/{access_id}",
summary="Get URL where you can retrieve files",
tags=["objects"],
)
async def get_object_bytes_url(object_id: str, access_id: str):
"""
Returns a URL that can be used to fetch the bytes of a DrsObject.
"""
ids = parse_bedbase_drs_object_id(object_id)
return bbc.get_object_uri(
ids["record_type"], ids["record_id"], ids["result_id"], access_id
)


@app.head(
"/objects/{object_id}/access/{access_id}/bytes", include_in_schema=False
) # Required by UCSC track hubs
@app.get(
"/objects/{object_id}/access/{access_id}/bytes",
summary="Download actual files",
tags=["objects"],
)
async def get_object_bytes(object_id: str, access_id: str):
"""
Returns the bytes of a DrsObject.
"""
ids = parse_bedbase_drs_object_id(object_id)
return bbc.serve_file(
bbc.get_object_uri(
ids["record_type"], ids["record_id"], ids["result_id"], access_id
)
)


@app.get("/")
async def index():
@app.get(
"/objects/{object_id}/access/{access_id}/thumbnail",
summary="Download thumbnail",
tags=["objects"],
)
async def get_object_thumbnail(object_id: str, access_id: str):
"""
Display the dummy index UI page
Returns the bytes of a thumbnail of a DrsObject
"""
return FileResponse(os.path.join(STATIC_PATH, "index.html"))
ids = parse_bedbase_drs_object_id(object_id)
return bbc.serve_file(
bbc.get_thumbnail_uri(
ids["record_type"], ids["record_id"], ids["result_id"], access_id
)
)


@app.get("/versions", response_model=Dict[str, str])
async def get_version_info():
# DRS-compatible API.
# Requires using `object_id` which has the form: `<record_type>.<record_id>.<object_class>`
# for example: `bed.326d5d77c7decf067bd4c7b42340c9a8.bedfile`
# or: `bed.421d2128e183424fcc6a74269bae7934.bedfile`
# bed.326d5d77c7decf067bd4c7b42340c9a8.bedfile
# bed.326d5d77c7decf067bd4c7b42340c9a8.bigbed
def parse_bedbase_drs_object_id(object_id: str):
"""
Returns app version information
Parse bedbase object id into its components
"""
versions = ALL_VERSIONS
versions.update({"openapi_version": get_openapi_version(app)})
return versions
try:
record_type, record_id, result_id = object_id.split(".")
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Object ID {object_id} is malformed. Should be of the form <record_type>.<record_id>.<result_id>",
)
if record_type not in ["bed", "bedset"]:
raise HTTPException(
status_code=400, detail=f"Object type {record_type} is incorrect"
)
return {
"record_type": record_type,
"record_id": record_id,
"result_id": result_id,
}


# General-purpose exception handlers (so we don't have to write try/catch blocks in every endpoint)

@app.exception_handler(MissingThumbnailError)
async def exc_handler_MissingThumbnailError(req: Request, exc: MissingThumbnailError):
return drs_response(404, "No thumbnail for this object.")


@app.exception_handler(BadAccessMethodError)
async def exc_handler_BadAccessMethodError(req: Request, exc: BadAccessMethodError):
return drs_response(404, "Requested access URL was not found.")


@app.exception_handler(ColumnNotFoundError)
async def exc_handler_ColumnNotFoundError(req: Request, exc: ColumnNotFoundError):
_LOGGER.error(f"ColumnNotFoundError: {exc}")
return drs_response(404, "Malformed result identifier.")


@app.exception_handler(RecordNotFoundError)
async def exc_handler_RecordNotFoundError(req: Request, exc: RecordNotFoundError):
return drs_response(404, "Record not found.")


@app.exception_handler(MissingObjectError)
async def exc_handler_MissingObjectError(req: Request, exc: MissingObjectError):
return drs_response(404, "Object not found.")


def main():
Expand All @@ -73,7 +270,7 @@ def main():
_LOGGER.info(f"Running {PKG_NAME} app...")
bbconf_file_path = args.config or os.environ.get("BEDBASE_CONFIG") or None
global bbc
bbc = configure(bbconf_file_path)
bbc = configure(bbconf_file_path, app)
attach_routers(app)
uvicorn.run(
app,
Expand All @@ -87,7 +284,7 @@ def main():
bbconf_file_path = os.environ.get("BEDBASE_CONFIG") or None
# must be configured before attaching routers to avoid circular imports
global bbc
bbc = configure(bbconf_file_path)
bbc = configure(bbconf_file_path, app)
attach_routers(app)
else:
raise EnvironmentError(
Expand Down
Loading

0 comments on commit 5de19f9

Please sign in to comment.