Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/deploy-docs-openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Deploy API Docs
on:
push:
branches: [main]
paths: ['APISpecification.yaml'] # Only run when spec changes

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Create documentation page
run: |
cat > index.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
<title>API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: './APISpecification.yaml',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.presets.standalone
]
});
</script>
</body>
</html>
EOF

- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./
publish_branch: gh-pages
42 changes: 42 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Deploy OpenAPI docs to GitHub Pages

on:
push:
branches:
- main
- add-genome-groups
paths:
- 'docs/**'
- '.github/workflows/deploy-docs.yml'

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: 'pages'
cancel-in-progress: true

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Pages
uses: actions/configure-pages@v5

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ docker-compose -f docker-compose.yml up
```bash
docker-compose -f docker-compose.yml run metadata_api python -m unittest
```

### Run locally without Docker:
```bash
git clone https://github.com/Ensembl/ensembl-web-metadata-api
cd ensembl-web-metadata-api
pip install -r requirements.txt
PYTHONPATH='app' uvicorn main:app --host 0.0.0.0 --port 8014 --reload
```

14 changes: 13 additions & 1 deletion app/api/models/genome.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,16 @@ class DatasetAttributes(BaseModel):
class GenomeByKeyword(BaseModel):
genome_uuid: str = Field(alias="genomeUuid", default="")
release_version: float = Field(alias=AliasPath("release", "releaseVersion"), default=0)
genome_tag: str = Field(alias=AliasPath("assembly", "urlName"), default="")
genome_tag: str = Field(alias=AliasPath("assembly", "urlName"), default="")

class GenomeGroup(BaseModel):
id: str = Field(alias="groupId")
type: str = Field(alias="groupType")
name: str | None = Field(alias="groupName", default=None)
reference_genome: BaseGenomeDetails = Field(alias="referenceGenome")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just remember that this is a nullable field. We should always have a reference genome for structural variant groups, but not for other groups.

Copy link
Contributor Author

@bilalebi bilalebi Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The object name is kinda deceiving at this stage, I think of changing it to SVGenomeGroup because it's SV specific


class GenomeGroupsResponse(BaseModel):
genome_groups: list[GenomeGroup] = Field(alias="genomeGroups")

class GenomesInGroupResponse(BaseModel):
genomes: list[BaseGenomeDetails] = Field(alias="genomes")
14 changes: 14 additions & 0 deletions app/api/resources/grpc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,17 @@ def get_release(self, release_label: list[str], current_only: bool):
request_class = self.reflector.message_class("ensembl_metadata.ReleaseRequest")
request = request_class(release_label=release_label, current_only=current_only)
return self.stub.GetRelease(request)

def get_genome_groups_with_reference(self, group_type: str, release_label: str):
request_class = self.reflector.message_class(
"ensembl_metadata.GroupTypeRequest"
)
request = request_class(group_type=group_type, release_label=release_label)
return self.stub.GetGenomeGroupsWithReference(request)

def get_genomes_in_group(self, group_id: str, release_label: str):
request_class = self.reflector.message_class(
"ensembl_metadata.GenomesInGroupRequest"
)
request = request_class(group_id=group_id, release_label=release_label)
return self.stub.GetGenomesInGroup(request)
59 changes: 57 additions & 2 deletions app/api/resources/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@
import logging
from typing import Annotated

from fastapi import APIRouter, Request, responses, Query, HTTPException
from fastapi import APIRouter, Request, responses, Query, Path
from pydantic import ValidationError

from api.error_response import response_error_handler
from api.models.checksums import Checksum
from api.models.statistics import GenomeStatistics, ExampleObjectList
from api.models.popular_species import PopularSpeciesGroup
from api.models.karyotype import Karyotype
from api.models.genome import BriefGenomeDetails, GenomeDetails, DatasetAttributes, GenomeByKeyword, Release
from api.models.genome import BriefGenomeDetails, GenomeDetails, DatasetAttributes, GenomeByKeyword, Release, \
GenomeGroupsResponse, GenomesInGroupResponse
from api.models.ftplinks import FTPLinks
from api.models.vep import VepFilePaths

Expand Down Expand Up @@ -347,3 +348,57 @@ async def get_releases(
response_data = responses.JSONResponse(error_response, status_code=500)

return response_data

@router.get("/genome_groups", name="genome_groups")
@redis_cache("genome_groups", arg_keys=["group_type", "release_label"])
async def get_genome_groups(
group_type: str = Query(..., description="Group type, e.g. 'structural_variant'"),
release_label: str | None = Query(None, description="Optional release label, e.g. '2025-02'")
):
try:
genome_groups_dict = MessageToDict(
grpc_client.get_genome_groups_with_reference(
group_type=group_type,
release_label=release_label
)
)
logging.debug(f"genome_groups_dict: {genome_groups_dict}")
if len(genome_groups_dict.get("genomeGroups", [])) == 0:
return response_error_handler({
"status": 404,
"details": "No genome groups found matching criteria"
})

genome_groups = GenomeGroupsResponse(**genome_groups_dict)
response_dict = genome_groups.model_dump()
return responses.JSONResponse(response_dict, status_code=200)
except Exception as ex:
logging.exception("Error in get_genome_groups")
return response_error_handler({"status": 500})

@router.get("/genome_groups/{group_id}/genomes", name="genomes_in_group")
@redis_cache("genomes_in_group", arg_keys=["group_id", "release_label"])
async def get_genomes_in_group(
group_id: str = Path(..., description="Group ID, e.g. 'grch38-group'"),
release_label: str | None = Query(None, description="Optional release label, e.g. '2025-02'")
):
try:
genomes_in_group_dict = MessageToDict(
grpc_client.get_genomes_in_group(
group_id=group_id,
release_label=release_label
)
)
logging.debug(f"genomes_in_group_dict: {genomes_in_group_dict}")
if len(genomes_in_group_dict.get("genomes", [])) == 0:
return response_error_handler({
"status": 404,
"details": "No genomes found in specified group"
})

genomes_in_group = GenomesInGroupResponse(**genomes_in_group_dict)
response_dict = genomes_in_group.model_dump()
return responses.JSONResponse(response_dict, status_code=200)
except Exception as ex:
logging.exception("Error in get_genomes_in_group")
return response_error_handler({"status": 500})
85 changes: 85 additions & 0 deletions APISpecification.yaml → docs/APISpecification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,62 @@ paths:
'500':
$ref: '#/components/responses/500InternalServerError'

/api/metadata/genome_groups:
get:
summary: List genome groups with their reference genome
parameters:
- in: query
name: group_type
required: true
schema:
type: string
- in: query
name: release_label
required: false
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
genome_groups:
type: array
items:
$ref: '#/components/schemas/GenomeGroup'
"400": { description: Bad Request }

/api/metadata/genome_groups/{group_id}/genomes:
get:
summary: List genomes in a group
parameters:
- in: path
name: group_id
required: true
schema:
type: string
- in: query
name: release_label
required: false
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
genomes:
type: array
items:
$ref: '#/components/schemas/Genome'
"404": { description: Not Found }

components:
schemas:
GenomeIDFromAssemblyAccessionResponse:
Expand Down Expand Up @@ -1072,6 +1128,35 @@ components:
- name
- type
- is_current
Genome:
type: object
properties:
genome_uuid:
type: string
format: uuid
common_name:
type: string
scientific_name:
type: string
assembly_name:
type: string
is_reference:
type: boolean
is_group_reference:
type: boolean
release:
$ref: '#/components/schemas/Release'
GenomeGroup:
type: object
properties:
group_id:
type: string
group_type:
type: string
group_name:
type: string
reference_genome:
$ref: '#/components/schemas/Genome'
responses:
404NotFound:
description: The specified resource was not found
Expand Down
30 changes: 30 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
<style>
body { margin: 0; padding: 0; }
</style>
</head>
<body>
<div id="swagger-ui"></div>

<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = () => {
SwaggerUIBundle({
url: "APISpecification.yaml", // IMPORTANT: relative path
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout"
});
};
</script>
</body>
</html>