diff --git a/lib/galaxy/config/schemas/tool_shed_config_schema.yml b/lib/galaxy/config/schemas/tool_shed_config_schema.yml index 116f0521f48..358215d7031 100644 --- a/lib/galaxy/config/schemas/tool_shed_config_schema.yml +++ b/lib/galaxy/config/schemas/tool_shed_config_schema.yml @@ -342,6 +342,58 @@ mapping: desc: | Address to join mailing list + ga4gh_service_id: + type: str + required: false + desc: | + Service ID for GA4GH services (exposed via the service-info endpoint for the Galaxy DRS API). + If unset, one will be generated using the URL the target API requests are made against. + + For more information on GA4GH service definitions - check out + https://github.com/ga4gh-discovery/ga4gh-service-registry + and https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml + + This value should likely reflect your service's URL. For instance for usegalaxy.org + this value should be org.usegalaxy. Particular Galaxy implementations will treat this + value as a prefix and append the service type to this ID. For instance for the DRS + service "id" (available via the DRS API) for the above configuration value would be + org.usegalaxy.drs. + + ga4gh_service_organization_name: + type: str + required: false + desc: | + Service name for host organization (exposed via the service-info endpoint for the Galaxy DRS API). + If unset, one will be generated using ga4gh_service_id. + + For more information on GA4GH service definitions - check out + https://github.com/ga4gh-discovery/ga4gh-service-registry + and https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml + + ga4gh_service_organization_url: + type: str + required: False + desc: | + Organization URL for host organization (exposed via the service-info endpoint for the Galaxy DRS API). + If unset, one will be generated using the URL the target API requests are made against. + + For more information on GA4GH service definitions - check out + https://github.com/ga4gh-discovery/ga4gh-service-registry + and https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml + + ga4gh_service_environment: + type: str + required: False + desc: | + Service environment (exposed via the service-info endpoint for the Galaxy DRS API) for + implemented GA4GH services. + + Suggested values are prod, test, dev, staging. + + For more information on GA4GH service definitions - check out + https://github.com/ga4gh-discovery/ga4gh-service-registry + and https://editor.swagger.io/?url=https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-registry/develop/service-registry.yaml + use_heartbeat: type: bool default: true diff --git a/lib/galaxy/webapps/base/api.py b/lib/galaxy/webapps/base/api.py index 2835a2340fa..73056bc88a0 100644 --- a/lib/galaxy/webapps/base/api.py +++ b/lib/galaxy/webapps/base/api.py @@ -165,7 +165,14 @@ def get_error_response_for_request(request: Request, exc: MessageException) -> J if "ga4gh" in path: # When serving GA4GH APIs use limited exceptions to conform their expected # error schema. Tailored to DRS currently. - content = {"status_code": status_code, "msg": error_dict["err_msg"]} + message = error_dict["err_msg"] + if "drs" in path: + content = {"status_code": status_code, "msg": message} + elif "trs" in path: + content = {"code": status_code, "message": message} + else: + # unknown schema - just yield the most useful error message + content = error_dict else: content = error_dict diff --git a/lib/tool_shed/managers/trs.py b/lib/tool_shed/managers/trs.py new file mode 100644 index 00000000000..c36488bdb36 --- /dev/null +++ b/lib/tool_shed/managers/trs.py @@ -0,0 +1,153 @@ +from typing import ( + Any, + cast, + Dict, + List, + Optional, + Tuple, +) + +from starlette.datastructures import URL + +from galaxy.exceptions import ObjectNotFound +from galaxy.util.tool_shed.common_util import remove_protocol_and_user_from_clone_url +from galaxy.version import VERSION +from tool_shed.context import ProvidesRepositoriesContext +from tool_shed.structured_app import ToolShedApp +from tool_shed.util.metadata_util import get_current_repository_metadata_for_changeset_revision +from tool_shed.webapp.model import ( + Repository, + RepositoryMetadata, +) +from tool_shed_client.schema.trs import ( + DescriptorType, + Tool, + ToolClass, + ToolVersion, +) +from tool_shed_client.schema.trs_service_info import ( + Organization, + Service, + ServiceType, +) +from tool_shed_client.trs_util import decode_identifier +from .repositories import guid_to_repository + +TRS_SERVICE_NAME = "Tool Shed TRS API" +TRS_SERVICE_DESCRIPTION = "Serves tool shed repository tools according to the GA4GH TRS specification" + + +def service_info(app: ToolShedApp, request_url: URL): + components = request_url.components + hostname = components.hostname + assert hostname + default_organization_id = ".".join(reversed(hostname.split("."))) + config = app.config + organization_id = cast(str, config.ga4gh_service_id or default_organization_id) + organization_name = cast(str, config.ga4gh_service_organization_name or organization_id) + organization_url = cast(str, config.ga4gh_service_organization_url or f"{components.scheme}://{components.netloc}") + + organization = Organization( + url=organization_url, + name=organization_name, + ) + service_type = ServiceType( + group="org.ga4gh", + artifact="trs", + version="2.1.0", + ) + environment = config.ga4gh_service_environment + extra_kwds = {} + if environment: + extra_kwds["environment"] = environment + return Service( + id=organization_id + ".trs", + name=TRS_SERVICE_NAME, + description=TRS_SERVICE_DESCRIPTION, + organization=organization, + type=service_type, + version=VERSION, + **extra_kwds, + ) + + +def tool_classes() -> List[ToolClass]: + return [ToolClass(id="galaxy_tool", name="Galaxy Tool", description="Galaxy XML Tools")] + + +def trs_tool_id_to_repository(trans: ProvidesRepositoriesContext, trs_tool_id: str) -> Repository: + guid = decode_identifier(trans.repositories_hostname, trs_tool_id) + guid = remove_protocol_and_user_from_clone_url(guid) + return guid_to_repository(trans.app, guid) + + +def get_repository_metadata_by_tool_version( + app: ToolShedApp, repository: Repository, tool_id: str +) -> Dict[str, RepositoryMetadata]: + versions = {} + for _, changeset in repository.installable_revisions(app): + metadata = get_current_repository_metadata_for_changeset_revision(app, repository, changeset) + tools: Optional[List[Dict[str, Any]]] = metadata.metadata.get("tools") + if not tools: + continue + for tool_metadata in tools: + if tool_metadata["id"] != tool_id: + continue + versions[tool_metadata["version"]] = metadata + return versions + + +def get_tools_for(repository_metadata: RepositoryMetadata) -> List[Dict[str, Any]]: + tools: Optional[List[Dict[str, Any]]] = repository_metadata.metadata.get("tools") + assert tools + return tools + + +def trs_tool_id_to_repository_metadata( + trans: ProvidesRepositoriesContext, trs_tool_id: str +) -> Optional[Tuple[Repository, Dict[str, RepositoryMetadata]]]: + tool_guid = decode_identifier(trans.repositories_hostname, trs_tool_id) + tool_guid = remove_protocol_and_user_from_clone_url(tool_guid) + _, tool_id = tool_guid.rsplit("/", 1) + repository = guid_to_repository(trans.app, tool_guid) + app = trans.app + versions: Dict[str, RepositoryMetadata] = get_repository_metadata_by_tool_version(app, repository, tool_id) + if not versions: + return None + + return repository, versions + + +def get_tool(trans: ProvidesRepositoriesContext, trs_tool_id: str) -> Tool: + guid = decode_identifier(trans.repositories_hostname, trs_tool_id) + guid = remove_protocol_and_user_from_clone_url(guid) + repo_metadata = trs_tool_id_to_repository_metadata(trans, trs_tool_id) + if not repo_metadata: + raise ObjectNotFound() + repository, metadata_by_version = repo_metadata + + repo_owner = repository.user.username + aliases: List[str] = [guid] + hostname = remove_protocol_and_user_from_clone_url(trans.repositories_hostname) + url = f"https://{hostname}/repos/{repo_owner}/{repository.name}" + + versions: List[ToolVersion] = [] + for tool_version_str, _ in metadata_by_version.items(): + version_url = url # TODO: + tool_version = ToolVersion( + author=[repo_owner], + containerfile=False, + descriptor_type=[DescriptorType.GALAXY], + id=tool_version_str, + url=version_url, + verified=False, + ) + versions.append(tool_version) + return Tool( + aliases=aliases, + id=trs_tool_id, + url=url, + toolclass=tool_classes()[0], + organization=repo_owner, + versions=versions, + ) diff --git a/lib/tool_shed/test/functional/test_shed_tools.py b/lib/tool_shed/test/functional/test_shed_tools.py index 103cfeecfbe..8d91ce088b7 100644 --- a/lib/tool_shed/test/functional/test_shed_tools.py +++ b/lib/tool_shed/test/functional/test_shed_tools.py @@ -1,4 +1,12 @@ -from ..base.api import ShedApiTestCase +from tool_shed_client.schema.trs import ( + Tool, + ToolClass, +) +from tool_shed_client.trs_util import encode_identifier +from ..base.api import ( + ShedApiTestCase, + skip_if_api_v1, +) class TestShedToolsApi(ShedApiTestCase): @@ -32,3 +40,31 @@ class TestShedToolsApi(ShedApiTestCase): # but if this tool has been installed a bunch by other tests - it might not be. tool_search_hit = response.find_search_hit(repository) assert tool_search_hit + + @skip_if_api_v1 + def test_trs_service_info(self): + service_info = self.api_interactor.get("ga4gh/trs/v2/service-info") + service_info.raise_for_status() + + @skip_if_api_v1 + def test_trs_tool_classes(self): + classes_response = self.api_interactor.get("ga4gh/trs/v2/toolClasses") + classes_response.raise_for_status() + classes = classes_response.json() + assert isinstance(classes, list) + assert len(classes) == 1 + class0 = classes[0] + assert ToolClass(**class0) + + @skip_if_api_v1 + def test_trs_tool_list(self): + populator = self.populator + repository = populator.setup_column_maker_repo(prefix="toolstrsindex") + tool_id = populator.tool_guid(self, repository, "Add_a_column1") + tool_shed_base, encoded_tool_id = encode_identifier(tool_id) + print(encoded_tool_id) + url = f"ga4gh/trs/v2/tools/{encoded_tool_id}" + print(url) + tool_response = self.api_interactor.get(url) + tool_response.raise_for_status() + assert Tool(**tool_response.json()) diff --git a/lib/tool_shed/webapp/api2/tools.py b/lib/tool_shed/webapp/api2/tools.py index b93502ed9e8..0d8c2f2d552 100644 --- a/lib/tool_shed/webapp/api2/tools.py +++ b/lib/tool_shed/webapp/api2/tools.py @@ -1,8 +1,27 @@ +import logging +from typing import List + +from fastapi import ( + Path, + Request, +) + from tool_shed.context import SessionRequestContext from tool_shed.managers.tools import search +from tool_shed.managers.trs import ( + get_tool, + service_info, + tool_classes, +) from tool_shed.structured_app import ToolShedApp from tool_shed.util.shed_index import build_index from tool_shed_client.schema import BuildSearchIndexResponse +from tool_shed_client.schema.trs import ( + Tool, + ToolClass, + ToolVersion, +) +from tool_shed_client.schema.trs_service_info import Service from . import ( depends, DependsOnTrans, @@ -12,8 +31,16 @@ from . import ( ToolsIndexQueryParam, ) +log = logging.getLogger(__name__) + router = Router(tags=["tools"]) +TOOL_ID_PATH_PARAM: str = Path( + ..., + title="GA4GH TRS Tool ID", + description="See also https://ga4gh.github.io/tool-registry-service-schemas/DataModel/#trs-tool-and-trs-tool-version-ids", +) + @router.cbv class FastAPITools: @@ -53,3 +80,44 @@ class FastAPITools: repositories_indexed=repos_indexed, tools_indexed=tools_indexed, ) + + @router.get("/api/ga4gh/trs/v2/service-info", operation_id="tools_trs_service_info") + def service_info(self, request: Request) -> Service: + return service_info(self.app, request.url) + + @router.get("/api/ga4gh/trs/v2/toolClasses", operation_id="tools__trs_tool_classes") + def tool_classes(self) -> List[ToolClass]: + return tool_classes() + + @router.get( + "/api/ga4gh/trs/v2/tools", + operation_id="tools__trs_index", + ) + def trs_index( + self, + ): + # we probably want to be able to query the database at the + # tool level and such to do this right? + return [] + + @router.get( + "/api/ga4gh/trs/v2/tools/{tool_id}", + operation_id="tools__trs_get", + ) + def trs_get( + self, + trans: SessionRequestContext = DependsOnTrans, + tool_id: str = TOOL_ID_PATH_PARAM, + ) -> Tool: + return get_tool(trans, tool_id) + + @router.get( + "/api/ga4gh/trs/v2/tools/{tool_id}/versions", + operation_id="tools__trs_get_versions", + ) + def trs_get_versions( + self, + trans: SessionRequestContext = DependsOnTrans, + tool_id: str = TOOL_ID_PATH_PARAM, + ) -> List[ToolVersion]: + return get_tool(trans, tool_id).versions diff --git a/lib/tool_shed_client/schema/gen.sh b/lib/tool_shed_client/schema/gen.sh new file mode 100755 index 00000000000..d6ee47360c5 --- /dev/null +++ b/lib/tool_shed_client/schema/gen.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# must be run from a virtualenv with... +# https://github.com/koxudaxi/datamodel-code-generator +#for model in AccessMethod Checksum DrsObject Error AccessURL ContentsObject DrsService +#do +# datamodel-codegen --url "https://raw.githubusercontent.com/ga4gh/tool-registry-service-schemas/develop/openapi/ga4gh-tool-discovery.yaml" --output "$model.py" +#one + +#datamodel-codegen --url "https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-info/v1.0.0/service-info.yaml#/components/schemas/Service" --output Service.py + +datamodel-codegen --url "https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-info/v1.0.0/service-info.yaml#/paths/~1service-info" --output trs_service_info.py +datamodel-codegen --url "https://raw.githubusercontent.com/ga4gh/tool-registry-service-schemas/develop/openapi/openapi.yaml" --output trs.py diff --git a/lib/tool_shed_client/schema/trs.py b/lib/tool_shed_client/schema/trs.py new file mode 100644 index 00000000000..fceff90e76f --- /dev/null +++ b/lib/tool_shed_client/schema/trs.py @@ -0,0 +1,216 @@ +# generated by datamodel-codegen: +# filename: https://raw.githubusercontent.com/ga4gh/tool-registry-service-schemas/develop/openapi/openapi.yaml +# timestamp: 2022-12-20T21:01:58+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import ( + Dict, + List, + Optional, + Union, +) + +from pydantic import ( + BaseModel, + Field, +) + + +class Checksum(BaseModel): + checksum: str = Field(..., description="The hex-string encoded checksum for the data. ") + type: str = Field( + ..., + description="The digest method used to create the checksum.\nThe value (e.g. `sha-256`) SHOULD be listed as `Hash Name String` in the https://github.com/ga4gh-discovery/ga4gh-checksum/blob/master/hash-alg.csv[GA4GH Checksum Hash Algorithm Registry].\nOther values MAY be used, as long as implementors are aware of the issues discussed in https://tools.ietf.org/html/rfc6920#section-9.4[RFC6920].\nGA4GH may provide more explicit guidance for use of non-IANA-registered algorithms in the future.", + ) + + +class FileType(Enum): + TEST_FILE = "TEST_FILE" + PRIMARY_DESCRIPTOR = "PRIMARY_DESCRIPTOR" + SECONDARY_DESCRIPTOR = "SECONDARY_DESCRIPTOR" + CONTAINERFILE = "CONTAINERFILE" + OTHER = "OTHER" + + +class ToolFile(BaseModel): + path: Optional[str] = Field( + None, + description="Relative path of the file. A descriptor's path can be used with the GA4GH .../{type}/descriptor/{relative_path} endpoint.", + ) + file_type: Optional[FileType] = None + checksum: Optional[Checksum] = None + + +class ToolClass(BaseModel): + id: Optional[str] = Field(None, description="The unique identifier for the class.") + name: Optional[str] = Field(None, description="A short friendly name for the class.") + description: Optional[str] = Field( + None, description="A longer explanation of what this class is and what it can accomplish." + ) + + +class ImageType(Enum): + Docker = "Docker" + Singularity = "Singularity" + Conda = "Conda" + + +class DescriptorType(Enum): + CWL = "CWL" + WDL = "WDL" + NFL = "NFL" + GALAXY = "GALAXY" + SMK = "SMK" + + +class DescriptorTypeVersion(BaseModel): + __root__: str = Field( + ..., + description="The language version for a given descriptor type. The version should correspond to the actual declared version of the descriptor. For example, tools defined in CWL could have a version of `v1.0.2` whereas WDL tools may have a version of `1.0` or `draft-2`", + ) + + +class DescriptorTypeWithPlain(Enum): + CWL = "CWL" + WDL = "WDL" + NFL = "NFL" + GALAXY = "GALAXY" + SMK = "SMK" + PLAIN_CWL = "PLAIN_CWL" + PLAIN_WDL = "PLAIN_WDL" + PLAIN_NFL = "PLAIN_NFL" + PLAIN_GALAXY = "PLAIN_GALAXY" + PLAIN_SMK = "PLAIN_SMK" + + +class FileWrapper(BaseModel): + content: Optional[str] = Field( + None, description="The content of the file itself. One of url or content is required." + ) + checksum: Optional[List[Checksum]] = Field( + None, + description="A production (immutable) tool version is required to have a hashcode. Not required otherwise, but might be useful to detect changes. ", + example=[{"checksum": "ea2a5db69bd20a42976838790bc29294df3af02b", "type": "sha1"}], + ) + image_type: Optional[Union[ImageType, DescriptorType]] = Field( + None, description="Optionally return additional information on the type of file this is" + ) + url: Optional[str] = Field( + None, + description="Optional url to the underlying content, should include version information, and can include a git hash. Note that this URL should resolve to the raw unwrapped content that would otherwise be available in content. One of url or content is required.", + example={ + "descriptorfile": { + "url": "https://raw.githubusercontent.com/ICGC-TCGA-PanCancer/pcawg_delly_workflow/ea2a5db69bd20a42976838790bc29294df3af02b/delly_docker/Delly.cwl" + }, + "containerfile": { + "url": "https://raw.githubusercontent.com/ICGC-TCGA-PanCancer/pcawg_delly_workflow/c83478829802b4d36374870843821abe1b625a71/delly_docker/Dockerfile" + }, + }, + ) + + +class Error(BaseModel): + code: int + message: Optional[str] = "Internal Server Error" + + +class ImageData(BaseModel): + registry_host: Optional[str] = Field( + None, + description="A docker registry or a URL to a Singularity registry. Used along with image_name to locate a specific image.", + example=["registry.hub.docker.com"], + ) + image_name: Optional[str] = Field( + None, + description="Used in conjunction with a registry_url if provided to locate images.", + example=["quay.io/seqware/seqware_full/1.1", "ubuntu:latest"], + ) + size: Optional[int] = Field(None, description="Size of the container in bytes.") + updated: Optional[str] = Field(None, description="Last time the container was updated.") + checksum: Optional[List[Checksum]] = Field( + None, + description="A production (immutable) tool version is required to have a hashcode. Not required otherwise, but might be useful to detect changes. This exposes the hashcode for specific image versions to verify that the container version pulled is actually the version that was indexed by the registry.", + example=[{"checksum": "77af4d6b9913e693e8d0b4b294fa62ade6054e6b2f1ffb617ac955dd63fb0182", "type": "sha256"}], + ) + image_type: Optional[ImageType] = None + + +class ToolVersion(BaseModel): + author: Optional[List[str]] = Field( + None, + description="Contact information for the author of this version of the tool in the registry. (More complex authorship information is handled by the descriptor).", + ) + name: Optional[str] = Field(None, description="The name of the version.") + url: str = Field( + ..., + description="The URL for this tool version in this registry.", + example="http://agora.broadinstitute.org/tools/123456/versions/1", + ) + id: str = Field( + ..., description="An identifier of the version of this tool for this particular tool registry.", example="v1" + ) + is_production: Optional[bool] = Field( + None, + description="This version of a tool is guaranteed to not change over time (for example, a tool built from a tag in git as opposed to a branch). A production quality tool is required to have a checksum", + ) + images: Optional[List[ImageData]] = Field( + None, + description="All known docker images (and versions/hashes) used by this tool. If the tool has to evaluate any of the docker images strings at runtime, those ones cannot be reported here.", + ) + descriptor_type: Optional[List[DescriptorType]] = Field( + None, description="The type (or types) of descriptors available." + ) + descriptor_type_version: Optional[Dict[str, List[DescriptorTypeVersion]]] = Field( + None, + description="A map providing information about the language versions used in this tool. The keys should be the same values used in the `descriptor_type` field, and the value should be an array of all the language versions used for the given `descriptor_type`. Depending on the `descriptor_type` (e.g. CWL) multiple version values may be used in a single tool.", + example='{\n "WDL": ["1.0", "1.0"],\n "CWL": ["v1.0.2"],\n "NFL": ["DSL2"]\n}\n', + ) + containerfile: Optional[bool] = Field( + None, + description="Reports if this tool has a containerfile available. (For Docker-based tools, this would indicate the presence of a Dockerfile)", + ) + meta_version: Optional[str] = Field( + None, + description="The version of this tool version in the registry. Iterates when fields like the description, author, etc. are updated.", + ) + verified: Optional[bool] = Field( + None, description="Reports whether this tool has been verified by a specific organization or individual." + ) + verified_source: Optional[List[str]] = Field( + None, description="Source of metadata that can support a verified tool, such as an email or URL." + ) + signed: Optional[bool] = Field(None, description="Reports whether this version of the tool has been signed.") + included_apps: Optional[List[str]] = Field( + None, + description="An array of IDs for the applications that are stored inside this tool.", + example=["https://bio.tools/tool/mytum.de/SNAP2/1", "https://bio.tools/bioexcel_seqqc"], + ) + + +class Tool(BaseModel): + url: str = Field( + ..., + description="The URL for this tool in this registry.", + example="http://agora.broadinstitute.org/tools/123456", + ) + id: str = Field(..., description="A unique identifier of the tool, scoped to this registry.", example=123456) + aliases: Optional[List[str]] = Field( + None, + description="Support for this parameter is optional for tool registries that support aliases.\nA list of strings that can be used to identify this tool which could be straight up URLs. \nThis can be used to expose alternative ids (such as GUIDs) for a tool\nfor registries. Can be used to match tools across registries.", + ) + organization: str = Field(..., description="The organization that published the image.") + name: Optional[str] = Field(None, description="The name of the tool.") + toolclass: ToolClass + description: Optional[str] = Field(None, description="The description of the tool.") + meta_version: Optional[str] = Field( + None, + description="The version of this tool in the registry. Iterates when fields like the description, author, etc. are updated.", + ) + has_checker: Optional[bool] = Field(None, description="Whether this tool has a checker tool associated with it.") + checker_url: Optional[str] = Field( + None, + description="Optional url to the checker tool that will exit successfully if this tool produced the expected result given test data.", + ) + versions: List[ToolVersion] = Field(..., description="A list of versions for this tool.") diff --git a/lib/tool_shed_client/schema/trs_service_info.py b/lib/tool_shed_client/schema/trs_service_info.py new file mode 100644 index 00000000000..68b2f04287b --- /dev/null +++ b/lib/tool_shed_client/schema/trs_service_info.py @@ -0,0 +1,87 @@ +# generated by datamodel-codegen: +# filename: https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-info/v1.0.0/service-info.yaml#/paths/~1service-info +# timestamp: 2022-12-20T21:01:57+00:00 + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import ( + AnyUrl, + BaseModel, + Field, +) + + +class Organization(BaseModel): + name: str = Field( + ..., description="Name of the organization responsible for the service", example="My organization" + ) + url: AnyUrl = Field( + ..., description="URL of the website of the organization (RFC 3986 format)", example="https://example.com" + ) + + +class ServiceType(BaseModel): + group: str = Field( + ..., + description="Namespace in reverse domain name format. Use `org.ga4gh` for implementations compliant with official GA4GH specifications. For services with custom APIs not standardized by GA4GH, or implementations diverging from official GA4GH specifications, use a different namespace (e.g. your organization's reverse domain name).", + example="org.ga4gh", + ) + artifact: str = Field( + ..., + description="Name of the API or GA4GH specification implemented. Official GA4GH types should be assigned as part of standards approval process. Custom artifacts are supported.", + example="beacon", + ) + version: str = Field( + ..., + description="Version of the API or specification. GA4GH specifications use semantic versioning.", + example="1.0.0", + ) + + +class Service(BaseModel): + id: str = Field( + ..., + description="Unique ID of this service. Reverse domain name notation is recommended, though not required. The identifier should attempt to be globally unique so it can be used in downstream aggregator services e.g. Service Registry.", + example="org.ga4gh.myservice", + ) + name: str = Field(..., description="Name of this service. Should be human readable.", example="My project") + type: ServiceType + description: Optional[str] = Field( + None, + description="Description of the service. Should be human readable and provide information about the service.", + example="This service provides...", + ) + organization: Organization = Field(..., description="Organization providing the service") + contactUrl: Optional[AnyUrl] = Field( + None, + description="URL of the contact for the provider of this service, e.g. a link to a contact form (RFC 3986 format), or an email (RFC 2368 format).", + example="mailto:support@example.com", + ) + documentationUrl: Optional[AnyUrl] = Field( + None, + description="URL of the documentation of this service (RFC 3986 format). This should help someone learn how to use your service, including any specifics required to access data, e.g. authentication.", + example="https://docs.myservice.example.com", + ) + createdAt: Optional[datetime] = Field( + None, + description="Timestamp describing when the service was first deployed and available (RFC 3339 format)", + example="2019-06-04T12:58:19Z", + ) + updatedAt: Optional[datetime] = Field( + None, + description="Timestamp describing when the service was last updated (RFC 3339 format)", + example="2019-06-04T12:58:19Z", + ) + environment: Optional[str] = Field( + None, + description="Environment the service is running in. Use this to distinguish between production, development and testing/staging deployments. Suggested values are prod, test, dev, staging. However this is advised and not enforced.", + example="test", + ) + version: str = Field( + ..., + description="Version of the service being described. Semantic versioning is recommended, but other identifiers, such as dates or commit hashes, are also allowed. The version should be changed whenever the service is updated.", + example="1.0.0", + ) diff --git a/lib/tool_shed_client/trs_util.py b/lib/tool_shed_client/trs_util.py new file mode 100644 index 00000000000..a9d46238b96 --- /dev/null +++ b/lib/tool_shed_client/trs_util.py @@ -0,0 +1,24 @@ +from typing import NamedTuple + + +class EncodedIdentifier(NamedTuple): + tool_shed_base: str + encoded_id: str + + +# TRS specified encoding/decoding according to... +# https://datatracker.ietf.org/doc/html/rfc3986#section-2.4 +# Failed to get whole tool shed IDs working with FastAPI +# - https://github.com/tiangolo/fastapi/issues/791#issuecomment-742799299 +# - urllib.parse.quote(identifier, safe='') will produce the URL fragements but +# but FastAPI eat them. + + +def decode_identifier(tool_shed_base: str, quoted_tool_id: str) -> str: + suffix = "/".join(quoted_tool_id.split("~")) + return f"{tool_shed_base}/repos/{suffix}" + + +def encode_identifier(identifier: str) -> EncodedIdentifier: + base, rest = identifier.split("/repos/", 1) + return EncodedIdentifier(base, "~".join(rest.split("/"))) diff --git a/pyproject.toml b/pyproject.toml index 6ab9b16296b..e99a973160f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -202,3 +202,5 @@ relative-imports-order = "closest-to-furthest" # Don't check some pyupgrade rules on generated files "lib/galaxy/schema/bco/*" = ["UP006", "UP007"] "lib/galaxy/schema/drs/*" = ["UP006", "UP007"] +"lib/tool_shed_client/schema/trs.py" = ["UP006", "UP007"] +"lib/tool_shed_client/schema/trs_service_info.py" = ["UP006", "UP007"] diff --git a/test/unit/tool_shed/test_trs_tool.py b/test/unit/tool_shed/test_trs_tool.py new file mode 100644 index 00000000000..79aef77bca4 --- /dev/null +++ b/test/unit/tool_shed/test_trs_tool.py @@ -0,0 +1,21 @@ +from tool_shed.context import ProvidesRepositoriesContext +from tool_shed.managers.trs import get_tool +from tool_shed.webapp.model import Repository +from tool_shed_client.schema.trs import Tool +from ._util import upload_directories_to_repository + + +def test_get_tool(provides_repositories: ProvidesRepositoriesContext, new_repository: Repository): + upload_directories_to_repository(provides_repositories, new_repository, "column_maker") + owner = new_repository.user.username + name = new_repository.name + encoded_id = f"{owner}~{name}~Add_a_column1" + tool: Tool = get_tool(provides_repositories, encoded_id) + assert tool + assert tool.organization == owner + assert tool.id == encoded_id + assert tool.aliases + assert tool.aliases[0] == f"localhost/repos/{owner}/{name}/Add_a_column1" + + tool_versions = tool.versions + assert len(tool_versions) == 3