diff --git a/client/src/api/landings.ts b/client/src/api/landings.ts new file mode 100644 index 000000000000..03bbfc01d840 --- /dev/null +++ b/client/src/api/landings.ts @@ -0,0 +1,4 @@ +import { type components } from "@/api/schema"; + +export type ClaimLandingPayload = components["schemas"]["ClaimLandingPayload"]; +export type WorkflowLandingRequest = components["schemas"]["WorkflowLandingRequest"]; diff --git a/client/src/components/Form/Elements/FormData/FormDataUri.vue b/client/src/components/Form/Elements/FormData/FormDataUri.vue new file mode 100644 index 000000000000..2af290f9398d --- /dev/null +++ b/client/src/components/Form/Elements/FormData/FormDataUri.vue @@ -0,0 +1,71 @@ + + + + diff --git a/client/src/components/Form/FormElement.vue b/client/src/components/Form/FormElement.vue index 72af07dd7f8d..51620e1c60eb 100644 --- a/client/src/components/Form/FormElement.vue +++ b/client/src/components/Form/FormElement.vue @@ -14,6 +14,7 @@ import type { FormParameterAttributes, FormParameterTypes, FormParameterValue } import FormBoolean from "./Elements/FormBoolean.vue"; import FormColor from "./Elements/FormColor.vue"; import FormData from "./Elements/FormData/FormData.vue"; +import FormDataUri from "./Elements/FormData/FormDataUri.vue"; import FormDataDialog from "./Elements/FormDataDialog.vue"; import FormDirectory from "./Elements/FormDirectory.vue"; import FormDrilldown from "./Elements/FormDrilldown/FormDrilldown.vue"; @@ -130,6 +131,14 @@ const elementId = computed(() => `form-element-${props.id}`); const hasAlert = computed(() => alerts.value.length > 0); const showPreview = computed(() => (collapsed.value && attrs.value["collapsible_preview"]) || props.disabled); const showField = computed(() => !collapsed.value && !props.disabled); +const isUriDataField = computed(() => { + const dataField = props.type == 'data'; + if (dataField && props.value && props.value.prototype.hasOwnProperty("src")) { + const src = props.value.src; + return src == "url"; + } + return true; +}); const previewText = computed(() => attrs.value["text_value"]); const helpText = computed(() => { @@ -285,6 +294,12 @@ function onAlert(value: string | undefined) { :options="attrs.options" :optional="attrs.optional" :multiple="attrs.multiple" /> + + + + diff --git a/client/src/components/Landing/WorkflowLanding.vue b/client/src/components/Landing/WorkflowLanding.vue new file mode 100644 index 000000000000..21c75baba10a --- /dev/null +++ b/client/src/components/Landing/WorkflowLanding.vue @@ -0,0 +1,62 @@ + + + diff --git a/client/src/components/Workflow/Run/WorkflowRun.vue b/client/src/components/Workflow/Run/WorkflowRun.vue index 4190257e3cdd..5e8d9dea8bb6 100644 --- a/client/src/components/Workflow/Run/WorkflowRun.vue +++ b/client/src/components/Workflow/Run/WorkflowRun.vue @@ -30,6 +30,7 @@ interface Props { preferSimpleForm?: boolean; simpleFormTargetHistory?: string; simpleFormUseJobCache?: boolean; + requestState?: Record; } const props = withDefaults(defineProps(), { @@ -37,6 +38,7 @@ const props = withDefaults(defineProps(), { preferSimpleForm: false, simpleFormTargetHistory: "current", simpleFormUseJobCache: false, + requestState: undefined, }); const loading = ref(true); @@ -200,6 +202,7 @@ defineExpose({ :target-history="simpleFormTargetHistory" :use-job-cache="simpleFormUseJobCache" :can-mutate-current-history="canRunOnHistory" + :request-state="requestState" @submissionSuccess="handleInvocations" @submissionError="handleSubmissionError" @showAdvanced="showAdvanced" /> diff --git a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue index e7e45723ff16..f0007b0886bf 100644 --- a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue +++ b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue @@ -99,6 +99,10 @@ export default { type: Boolean, required: true, }, + requestState: { + type: Object, + required: false, + } }, setup() { const { config, isConfigLoaded } = useConfig(true); @@ -135,6 +139,7 @@ export default { if (isWorkflowInput(step.step_type)) { const stepName = new String(step.step_index); const stepLabel = step.step_label || new String(step.step_index + 1); + const stepType = step.step_type; const help = step.annotation; const longFormInput = step.inputs[0]; const stepAsInput = Object.assign({}, longFormInput, { @@ -142,10 +147,14 @@ export default { help: help, label: stepLabel, }); + if (this.requestState[stepLabel]) { + const value = this.requestState[stepLabel]; + stepAsInput.value = value; + } // disable collection mapping... stepAsInput.flavor = "module"; inputs.push(stepAsInput); - this.inputTypes[stepName] = step.step_type; + this.inputTypes[stepName] = stepType; } }); return inputs; diff --git a/client/src/entry/analysis/modules/Home.vue b/client/src/entry/analysis/modules/Home.vue index ecb9a82ac3f9..3e928a96de4f 100644 --- a/client/src/entry/analysis/modules/Home.vue +++ b/client/src/entry/analysis/modules/Home.vue @@ -13,6 +13,8 @@ import WorkflowRun from "components/Workflow/Run/WorkflowRun"; import decodeUriComponent from "decode-uri-component"; import CenterFrame from "entry/analysis/modules/CenterFrame"; +import WorkflowLanding from "./WorkflowLanding"; + export default { components: { CenterFrame, diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index 0128829043b9..18024224e9d3 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -17,6 +17,8 @@ import HistoryImport from "components/HistoryImport"; import InteractiveTools from "components/InteractiveTools/InteractiveTools"; import JobDetails from "components/JobInformation/JobDetails"; import CarbonEmissionsCalculations from "components/JobMetrics/CarbonEmissions/CarbonEmissionsCalculations"; +import ToolLanding from "components/Landing/ToolLanding"; +import WorkflowLanding from "components/Landing/WorkflowLanding"; import PageDisplay from "components/PageDisplay/PageDisplay"; import PageEditor from "components/PageEditor/PageEditor"; import ToolSuccess from "components/Tool/ToolSuccess"; @@ -494,6 +496,16 @@ export function getRouter(Galaxy) { path: "tools/json", component: ToolsJson, }, + { + path: "tool_landings/:uuid", + component: ToolLanding, + props: true, + }, + { + path: "workflow_landings/:uuid", + component: WorkflowLanding, + props: true, + }, { path: "user", component: UserPreferences, diff --git a/lib/galaxy/exceptions/__init__.py b/lib/galaxy/exceptions/__init__.py index f8e8c956ac98..749b80a57433 100644 --- a/lib/galaxy/exceptions/__init__.py +++ b/lib/galaxy/exceptions/__init__.py @@ -224,6 +224,11 @@ class UserActivationRequiredException(MessageException): err_code = error_codes_by_name["USER_ACTIVATION_REQUIRED"] +class ItemAlreadyClaimedException(MessageException): + status_code = 403 + err_code = error_codes_by_name["ITEM_IS_CLAIMED"] + + class ObjectNotFound(MessageException): """Accessed object was not found""" diff --git a/lib/galaxy/exceptions/error_codes.json b/lib/galaxy/exceptions/error_codes.json index 1c57fbc7bbad..cfaca436a65c 100644 --- a/lib/galaxy/exceptions/error_codes.json +++ b/lib/galaxy/exceptions/error_codes.json @@ -144,6 +144,11 @@ "code": 403007, "message": "Action requires account activation." }, + { + "name": "ITEM_IS_CLAIMED", + "code": 403008, + "message": "This item has already been claimed and cannot be re-claimed." + }, { "name": "USER_REQUIRED", "code": 403008, diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py new file mode 100644 index 000000000000..672a9b3783e7 --- /dev/null +++ b/lib/galaxy/managers/landing.py @@ -0,0 +1,139 @@ +import json +from typing import ( + Optional, + Union, +) +from uuid import uuid4 + +from pydantic import UUID4 +from sqlalchemy import select + +from galaxy.exceptions import ( + InsufficientPermissionsException, + ItemAlreadyClaimedException, + ObjectNotFound, + RequestParameterMissingException, +) +from galaxy.model import ( + ToolLandingRequest as ToolLandingRequestModel, + WorkflowLandingRequest as WorkflowLandingRequestModel, +) +from galaxy.model.base import transaction +from galaxy.model.scoped_session import galaxy_scoped_session +from galaxy.schema.schema import ( + ClaimLandingPayload, + CreateToolLandingRequestPayload, + CreateWorkflowLandingRequestPayload, + LandingRequestState, + ToolLandingRequest, + WorkflowLandingRequest, +) +from galaxy.util import safe_str_cmp +from .context import ProvidesUserContext + +LandingRequestModel = Union[ToolLandingRequestModel, WorkflowLandingRequestModel] + + +class LandingRequestManager: + + def __init__(self, sa_session: galaxy_scoped_session): + self.sa_session = sa_session + + def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload) -> ToolLandingRequest: + model = ToolLandingRequestModel() + model.tool_id = payload.tool_id + model.tool_version = payload.tool_version + model.request_state = payload.request_state + model.uuid = uuid4() + model.client_secret = payload.client_secret + self._save(model) + return self._tool_response(model) + + def create_workflow_landing_request(self, payload: CreateWorkflowLandingRequestPayload) -> WorkflowLandingRequest: + response_model = WorkflowLandingRequest( + workflow_id=payload.workflow_id, + workflow_target_type=payload.workflow_target_type, + request_state=payload.request_state, + uuid=uuid4(), + state=LandingRequestState.UNCLAIMED, + ) + + with open("request.json", "w") as f: + f.write(json.dumps(response_model.model_dump(mode="json"))) + return response_model + + def claim_tool_landing_request( + self, trans: ProvidesUserContext, uuid: UUID4, claim: Optional[ClaimLandingPayload] + ) -> ToolLandingRequest: + request: ToolLandingRequestModel = self._get_tool_landing_request(uuid) + self._check_can_claim(trans, request, claim) + request.user_id = trans.user.id + self._save(request) + return self._tool_response(request) + + def claim_workflow_landing_request( + self, trans: ProvidesUserContext, uuid: UUID4, claim: Optional[ClaimLandingPayload] + ) -> WorkflowLandingRequest: + with open("request.json") as f_in: + request = WorkflowLandingRequest.model_validate(json.load(f_in)) + request.state = LandingRequestState.CLAIMED + + with open("request.json", "w") as f_out: + f_out.write(json.dumps(request.model_dump(mode="json"))) + return request + + def get_tool_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> ToolLandingRequest: + request = self._get_claimed_tool_landing_request(trans, uuid) + return self._tool_response(request) + + def get_workflow_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> WorkflowLandingRequest: + with open("request.json") as f_in: + request = WorkflowLandingRequest.model_validate(json.load(f_in)) + return request + + def _check_can_claim( + self, trans: ProvidesUserContext, request: ToolLandingRequestModel, claim: Optional[ClaimLandingPayload] + ): + if request.client_secret is not None: + if claim is None or not claim.client_secret: + raise RequestParameterMissingException() + if not safe_str_cmp(request.client_secret, claim.client_secret): + raise InsufficientPermissionsException() + if request.user_id is not None: + raise ItemAlreadyClaimedException() + + def _get_tool_landing_request(self, uuid: UUID4) -> ToolLandingRequestModel: + request = self.sa_session.scalars( + select(ToolLandingRequestModel).where(ToolLandingRequestModel.uuid == str(uuid)) + ).one_or_none() + if request is None: + raise ObjectNotFound() + return request + + def _get_claimed_tool_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> ToolLandingRequestModel: + request = self._get_tool_landing_request(uuid) + self._check_ownership(trans, request) + return request + + def _tool_response(self, model: ToolLandingRequestModel) -> ToolLandingRequest: + response_model = ToolLandingRequest( + tool_id=model.tool_id, + tool_version=model.tool_version, + request_state=model.request_state, + uuid=model.uuid, + state=self._state(model), + ) + return response_model + + def _check_ownership(self, trans: ProvidesUserContext, model: LandingRequestModel): + if model.user_id != trans.user.id: + raise InsufficientPermissionsException() + + def _state(self, model: LandingRequestModel) -> LandingRequestState: + return LandingRequestState.UNCLAIMED if model.user_id is None else LandingRequestState.CLAIMED + + def _save(self, model: LandingRequestModel): + sa_session = self.sa_session + sa_session.add(model) + with transaction(sa_session): + sa_session.commit() diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index cec3a85f87da..91615dc424eb 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -11218,6 +11218,43 @@ def file_source_configuration( raise ValueError("No template sent to file_source_configuration") +# TODO: add link from tool_request to this +class ToolLandingRequest(Base): + __tablename__ = "tool_landing_request" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("galaxy_user.id"), index=True) + create_time: Mapped[datetime] = mapped_column(default=now, nullable=True) + update_time: Mapped[Optional[datetime]] = mapped_column(index=True, default=now, onupdate=now, nullable=True) + uuid: Mapped[Union[UUID, str]] = mapped_column(UUIDType(), index=True) + tool_id: Mapped[str] = mapped_column(String(255)) + tool_version: Mapped[Optional[str]] = mapped_column(String(255), default=None) + request_state: Mapped[Optional[Dict]] = mapped_column(JSONType) + client_secret: Mapped[Optional[str]] = mapped_column(String(255), default=None) + + user: Mapped[Optional["User"]] = relationship() + + +# TODO: add link from workflow_invocation to this +class WorkflowLandingRequest(Base): + __tablename__ = "workflow_landing_request" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("galaxy_user.id"), index=True) + workflow_id: Mapped[Optional[int]] = mapped_column(ForeignKey("stored_workflow.id"), nullable=True) + stored_workflow_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow.id"), nullable=True) + + create_time: Mapped[datetime] = mapped_column(default=now, nullable=True) + update_time: Mapped[Optional[datetime]] = mapped_column(index=True, default=now, onupdate=now, nullable=True) + uuid: Mapped[Union[UUID, str]] = mapped_column(UUIDType(), index=True) + request_state: Mapped[Optional[Dict]] = mapped_column(JSONType) + client_secret: Mapped[Optional[str]] = mapped_column(String(255), default=None) + + user: Mapped[Optional["User"]] = relationship() + stored_workflow: Mapped[Optional["StoredWorkflow"]] = relationship() + workflow: Mapped[Optional["Workflow"]] = relationship() + + class UserAction(Base, RepresentById): __tablename__ = "user_action" diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index b43f849048a7..98270eed102b 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -3754,6 +3754,49 @@ class ToolRequestModel(Model): state_message: Optional[str] +class LandingRequestState(str, Enum): + UNCLAIMED = "unclaimed" + CLAIMED = "claimed" + + +ToolLandingRequestIdField = Field(title="ID", description="Encoded ID of the tool landing request") +WorkflowLandingRequestIdField = Field(title="ID", description="Encoded ID of the workflow landing request") + + +class CreateToolLandingRequestPayload(Model): + tool_id: str + tool_version: Optional[str] = None + request_state: Optional[Dict[str, Any]] = None + client_secret: Optional[str] = None + + +class CreateWorkflowLandingRequestPayload(Model): + workflow_id: str + workflow_target_type: Literal["stored_workflow", "workflow"] + request_state: Optional[Dict[str, Any]] = None + client_secret: Optional[str] = None + + +class ClaimLandingPayload(Model): + client_secret: Optional[str] = None + + +class ToolLandingRequest(Model): + uuid: UuidField + tool_id: str + tool_version: Optional[str] = None + request_state: Optional[Dict[str, Any]] = None + state: LandingRequestState + + +class WorkflowLandingRequest(Model): + uuid: UuidField + workflow_id: str + workflow_target_type: Literal["stored_workflow", "workflow"] + request_state: Dict[str, Any] + state: LandingRequestState + + class AsyncFile(Model): storage_request_id: UUID task: AsyncTaskResultSummary diff --git a/lib/galaxy/tool_util/client/landing.py b/lib/galaxy/tool_util/client/landing.py new file mode 100644 index 000000000000..9519686ad59a --- /dev/null +++ b/lib/galaxy/tool_util/client/landing.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python + +# . .venv/bin/activate; PYTHONPATH=lib python lib/galaxy/tool_util/client/landing.py -g http://localhost:8081 simple_workflow + +import argparse +import random +import string +import sys +from dataclasses import dataclass +from typing import Optional + +import requests +import yaml + +from galaxy.util.resources import resource_string + +DESCRIPTION = """ +A small utility for demoing creation of tool and workflow landing endpoints. + +This allows an external developer to create tool and workflow forms with +pre-populated parameters and handing them off with URLs to their clients/users. +""" +RANDOM_SECRET_LENGTH = 10 + + +def load_default_library(): + library_yaml = resource_string("galaxy.tool_util.client", "landing_library.sample.yml") + return yaml.safe_load(library_yaml) + + +@dataclass +class Request: + template_id: str + library: str + client_secret: Optional[str] + galaxy_url: str + + +@dataclass +class Response: + landing_url: str + + +def generate_claim_url(request: Request) -> Response: + template_id = request.template_id + library_path = request.library + galaxy_url = request.galaxy_url + client_secret = request.client_secret + if client_secret == "__GEN__": + client_secret = "".join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(RANDOM_SECRET_LENGTH) + ) + if library_path: + with open(library_path) as f: + library = yaml.safe_load(f) + else: + library = load_default_library() + template = library[template_id] + template_type = "tool" if "tool_id" in template else "workflow" + if client_secret: + template["client_secret"] = client_secret + + landing_request_url = f"{galaxy_url}/api/{template_type}_landings" + raw_response = requests.post( + landing_request_url, + json=template, + ) + raw_response.raise_for_status() + response = raw_response.json() + url = f"{galaxy_url}/{template_type}_landings/{response['uuid']}" + if client_secret: + url = url + f"?secret={client_secret}" + return Response(url) + + +def arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=DESCRIPTION) + parser.add_argument("template_id") + parser.add_argument( + "-g", + "--galaxy-url", + dest="galaxy_url", + default="https://usegalaxy.org/", + help="Galxy target for the landing request", + ) + parser.add_argument( + "-l", + "--library", + dest="library", + default=None, + help="YAML library to load landing request templates from.", + ) + parser.add_argument( + "-s", + "--secret", + dest="secret", + default=None, + help="An optional client secret to verify the request against, set to __GEN__ to generate one at random for this request.", + ) + return parser + + +def main(argv=None) -> None: + if argv is None: + argv = sys.argv[1:] + + args = arg_parser().parse_args(argv) + request = Request( + args.template_id, + args.library, + args.secret, + args.galaxy_url, + ) + response = generate_claim_url(request) + print(f"Your customized form is located at {response.landing_url}") + + +if __name__ == "__main__": + main() diff --git a/lib/galaxy/tool_util/client/landing_library.sample.yml b/lib/galaxy/tool_util/client/landing_library.sample.yml new file mode 100644 index 000000000000..d5d505a8f102 --- /dev/null +++ b/lib/galaxy/tool_util/client/landing_library.sample.yml @@ -0,0 +1,13 @@ +simple_workflow: + workflow_id: f2db41e1fa331b3e + workflow_target_type: stored_workflow + request_state: + myinput: + src: url + url: https://raw.githubusercontent.com/galaxyproject/galaxy/dev/test-data/1.bed + ext: txt +int_workflow: + workflow_id: f597429621d6eb2b + workflow_target_type: stored_workflow + request_state: + int_input: 8 diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index 3b96292f955d..260f90cebced 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -26,6 +26,7 @@ APIRouter, Form, Header, + Path, Query, Request, Response, @@ -41,7 +42,10 @@ HTTPAuthorizationCredentials, HTTPBearer, ) -from pydantic import ValidationError +from pydantic import ( + UUID4, + ValidationError, +) from pydantic.main import BaseModel from routes import ( Mapper, @@ -618,3 +622,10 @@ def search_query_param(model_name: str, tags: list, free_text_fields: list) -> O title="Search query.", description=description, ) + + +LandingUuidPathParam: UUID4 = Path( + ..., + title="Landing UUID", + description="The UUID used to identify a persisted landing request.", +) diff --git a/lib/galaxy/webapps/galaxy/api/tools.py b/lib/galaxy/webapps/galaxy/api/tools.py index 9b6ea943f0cb..85a7c6a85f2c 100644 --- a/lib/galaxy/webapps/galaxy/api/tools.py +++ b/lib/galaxy/webapps/galaxy/api/tools.py @@ -17,6 +17,7 @@ Request, UploadFile, ) +from pydantic import UUID4 from starlette.datastructures import UploadFile as StarletteUploadFile from galaxy import ( @@ -26,16 +27,25 @@ ) from galaxy.datatypes.data import get_params_and_input_name from galaxy.managers.collections import DatasetCollectionManager -from galaxy.managers.context import ProvidesHistoryContext +from galaxy.managers.context import ( + ProvidesHistoryContext, + ProvidesUserContext, +) from galaxy.managers.hdas import HDAManager from galaxy.managers.histories import HistoryManager +from galaxy.managers.landing import LandingRequestManager from galaxy.model import ToolRequest from galaxy.schema.fetch_data import ( FetchDataFormPayload, FetchDataPayload, ) from galaxy.schema.fields import DecodedDatabaseIdField -from galaxy.schema.schema import ToolRequestModel +from galaxy.schema.schema import ( + ClaimLandingPayload, + CreateToolLandingRequestPayload, + ToolLandingRequest, + ToolRequestModel, +) from galaxy.tool_util.parameters import ToolParameterT from galaxy.tool_util.verify import ToolTestDescriptionDict from galaxy.tools.evaluation import global_tool_errors @@ -59,6 +69,7 @@ BaseGalaxyAPIController, depends, DependsOnTrans, + LandingUuidPathParam, Router, ) @@ -95,6 +106,7 @@ class JsonApiRoute(APIContentTypeRoute): @router.cbv class FetchTools: service: ToolsService = depends(ToolsService) + landing_manager: LandingRequestManager = depends(LandingRequestManager) @router.post("/api/tools/fetch", summary="Upload files to Galaxy", route_class_override=JsonApiRoute) async def fetch_json(self, payload: FetchDataPayload = Body(...), trans: ProvidesHistoryContext = DependsOnTrans): @@ -160,6 +172,39 @@ def _get_tool_request_or_raise_not_found( assert tool_request return tool_request + @router.post("/api/tool_landings", public=True) + def create_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + tool_landing_request: CreateToolLandingRequestPayload = Body(...), + ) -> ToolLandingRequest: + try: + return self.landing_manager.create_tool_landing_request(tool_landing_request) + except Exception: + log.exception("Problem...") + raise + + @router.post("/api/tool_landings/{uuid}/claim") + def claim_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + payload: Optional[ClaimLandingPayload] = Body(...), + ) -> ToolLandingRequest: + try: + return self.landing_manager.claim_tool_landing_request(trans, uuid, payload) + except Exception: + log.exception("claiim problem...") + raise + + @router.get("/api/tool_landings/{uuid}") + def get_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + ) -> ToolLandingRequest: + return self.landing_manager.get_tool_landing_request(trans, uuid) + @router.get( "/api/tools/{tool_id}/inputs", summary="Get tool inputs.", diff --git a/lib/galaxy/webapps/galaxy/api/workflows.py b/lib/galaxy/webapps/galaxy/api/workflows.py index e63063f871a4..d2dbe7b1576c 100644 --- a/lib/galaxy/webapps/galaxy/api/workflows.py +++ b/lib/galaxy/webapps/galaxy/api/workflows.py @@ -42,6 +42,7 @@ ProvidesHistoryContext, ProvidesUserContext, ) +from galaxy.managers.landing import LandingRequestManager from galaxy.managers.workflows import ( MissingToolsException, RefactorRequest, @@ -68,12 +69,15 @@ from galaxy.schema.schema import ( AsyncFile, AsyncTaskResultSummary, + ClaimLandingPayload, + CreateWorkflowLandingRequestPayload, InvocationSortByEnum, InvocationsStateCounts, SetSlugPayload, ShareWithPayload, ShareWithStatus, SharingStatus, + WorkflowLandingRequest, WorkflowSortByEnum, ) from galaxy.schema.workflows import ( @@ -102,6 +106,7 @@ depends, DependsOnTrans, IndexQueryTag, + LandingUuidPathParam, Router, search_query_param, ) @@ -895,6 +900,7 @@ def __get_stored_workflow(self, trans, workflow_id, **kwd): @router.cbv class FastAPIWorkflows: service: WorkflowsService = depends(WorkflowsService) + landing_manager: LandingRequestManager = depends(LandingRequestManager) @router.get( "/api/workflows", @@ -1145,6 +1151,39 @@ def show_workflow( ) -> StoredWorkflowDetailed: return self.service.show_workflow(trans, workflow_id, instance, legacy, version) + @router.post("/api/workflow_landings", public=True) + def create_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + workflow_landing_request: CreateWorkflowLandingRequestPayload = Body(...), + ) -> WorkflowLandingRequest: + try: + return self.landing_manager.create_workflow_landing_request(workflow_landing_request) + except Exception: + log.exception("Problem...") + raise + + @router.post("/api/workflow_landings/{uuid}/claim") + def claim_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + payload: Optional[ClaimLandingPayload] = Body(...), + ) -> WorkflowLandingRequest: + try: + return self.landing_manager.claim_workflow_landing_request(trans, uuid, payload) + except Exception: + log.exception("claiim problem...") + raise + + @router.get("/api/workflow_landings/{uuid}") + def get_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + ) -> WorkflowLandingRequest: + return self.landing_manager.get_workflow_landing_request(trans, uuid) + StepDetailQueryParam = Annotated[ bool, diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 951926dcc35b..37780b8f0f4e 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -227,6 +227,8 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs): webapp.add_client_route("/login/start") webapp.add_client_route("/tools/list") webapp.add_client_route("/tools/json") + webapp.add_client_route("/tool_landings/{uuid}") + webapp.add_client_route("/workflow_landings/{uuid}") webapp.add_client_route("/tours") webapp.add_client_route("/tours/{tour_id}") webapp.add_client_route("/user") diff --git a/lib/galaxy_test/api/test_landing.py b/lib/galaxy_test/api/test_landing.py new file mode 100644 index 000000000000..6f62367e363f --- /dev/null +++ b/lib/galaxy_test/api/test_landing.py @@ -0,0 +1,69 @@ +from base64 import b64encode +from typing import ( + Any, + Dict, +) + +from galaxy.schema.schema import ( + CreateToolLandingRequestPayload, + CreateWorkflowLandingRequestPayload, +) +from galaxy_test.base.populators import ( + DatasetPopulator, + skip_without_tool, + WorkflowPopulator, +) +from ._framework import ApiTestCase + + +class TestLandingApi(ApiTestCase): + dataset_populator: DatasetPopulator + workflow_populator: WorkflowPopulator + + def setUp(self): + super().setUp() + self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + self.workflow_populator = WorkflowPopulator(self.galaxy_interactor) + + @skip_without_tool("cat") + def test_tool_landing(self): + request = CreateToolLandingRequestPayload( + tool_id="create_2", + tool_version=None, + request_state={"sleep_time": 0}, + ) + response = self.dataset_populator.create_tool_landing(request) + assert response.tool_id == "create_2" + assert response.state == "unclaimed" + response = self.dataset_populator.claim_tool_landing(response.uuid) + assert response.tool_id == "create_2" + assert response.state == "claimed" + + @skip_without_tool("cat1") + def test_workflow_landing(self): + workflow_id = self.workflow_populator.simple_workflow("test_landing") + workflow_target_type = "stored_workflow" + request_state = _workflow_request_state() + request = CreateWorkflowLandingRequestPayload( + workflow_id=workflow_id, + workflow_target_type=workflow_target_type, + request_state=request_state, + ) + response = self.dataset_populator.create_workflow_landing(request) + assert response.workflow_id == workflow_id + assert response.workflow_target_type == workflow_target_type + + response = self.dataset_populator.claim_workflow_landing(response.uuid) + assert response.workflow_id == workflow_id + assert response.workflow_target_type == workflow_target_type + + +def _workflow_request_state() -> Dict[str, Any]: + deferred = False + input_b64_1 = b64encode(b"1 2 3").decode("utf-8") + input_b64_2 = b64encode(b"4 5 6").decode("utf-8") + inputs = { + "WorkflowInput1": {"src": "url", "url": f"base64://{input_b64_1}", "ext": "txt", "deferred": deferred}, + "WorkflowInput2": {"src": "url", "url": f"base64://{input_b64_2}", "ext": "txt", "deferred": deferred}, + } + return inputs diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index 10cf64c36009..10a5c2cbc3cf 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -77,10 +77,17 @@ ImporterGalaxyInterface, ) from gxformat2.yaml import ordered_load +from pydantic import UUID4 from requests import Response from rocrate.rocrate import ROCrate from typing_extensions import Literal +from galaxy.schema.schema import ( + CreateToolLandingRequestPayload, + CreateWorkflowLandingRequestPayload, + ToolLandingRequest, + WorkflowLandingRequest, +) from galaxy.tool_util.client.staging import InteractorStaging from galaxy.tool_util.cwl.util import ( download_output, @@ -369,7 +376,9 @@ class BasePopulator(metaclass=ABCMeta): galaxy_interactor: ApiTestInteractor @abstractmethod - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: """POST data to target Galaxy instance on specified route.""" @abstractmethod @@ -758,6 +767,34 @@ def _wait_for_purge(): wait_on(_wait_for_purge, "dataset to become purged", timeout=2) return self._get(dataset_url) + def create_tool_landing(self, payload: CreateToolLandingRequestPayload) -> ToolLandingRequest: + create_url = "tool_landings" + json = payload.model_dump(mode="json") + create_response = self._post(create_url, json, json=True, anon=True) + api_asserts.assert_status_code_is(create_response, 200) + create_response.raise_for_status() + return ToolLandingRequest.model_validate(create_response.json()) + + def create_workflow_landing(self, payload: CreateWorkflowLandingRequestPayload) -> WorkflowLandingRequest: + create_url = "workflow_landings" + json = payload.model_dump(mode="json") + create_response = self._post(create_url, json, json=True, anon=True) + api_asserts.assert_status_code_is(create_response, 200) + create_response.raise_for_status() + return WorkflowLandingRequest.model_validate(create_response.json()) + + def claim_tool_landing(self, uuid: UUID4) -> ToolLandingRequest: + url = f"tool_landings/{uuid}/claim" + claim_response = self._post(url, {"client_secret": "foobar"}, json=True) + api_asserts.assert_status_code_is(claim_response, 200) + return ToolLandingRequest.model_validate(claim_response.json()) + + def claim_workflow_landing(self, uuid: UUID4) -> WorkflowLandingRequest: + url = f"workflow_landings/{uuid}/claim" + claim_response = self._post(url, {"client_secret": "foobar"}, json=True) + api_asserts.assert_status_code_is(claim_response, 200) + return WorkflowLandingRequest.model_validate(claim_response.json()) + def create_tool_from_path(self, tool_path: str) -> Dict[str, Any]: tool_directory = os.path.dirname(os.path.abspath(tool_path)) payload = dict( @@ -1694,8 +1731,10 @@ class GalaxyInteractorHttpMixin: def _api_key(self): return self.galaxy_interactor.api_key - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: - return self.galaxy_interactor.post(route, data, files=files, admin=admin, headers=headers, json=json) + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: + return self.galaxy_interactor.post(route, data, files=files, admin=admin, headers=headers, json=json, anon=anon) def _put(self, route, data=None, headers=None, admin=False, json: bool = False): return self.galaxy_interactor.put(route, data, headers=headers, admin=admin, json=json) @@ -3339,11 +3378,14 @@ def _api_url(self): def _get(self, route, data=None, headers=None, admin=False) -> Response: return self._gi.make_get_request(self._url(route), params=data) - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: if headers is None: headers = {} headers = headers.copy() - headers["x-api-key"] = self._gi.key + if not anon: + headers["x-api-key"] = self._gi.key return requests.post(self._url(route), data=data, headers=headers, timeout=DEFAULT_SOCKET_TIMEOUT) def _put(self, route, data=None, headers=None, admin=False, json: bool = False): diff --git a/lib/galaxy_test/selenium/framework.py b/lib/galaxy_test/selenium/framework.py index 310c88c88053..9c300dc7b0de 100644 --- a/lib/galaxy_test/selenium/framework.py +++ b/lib/galaxy_test/selenium/framework.py @@ -744,12 +744,14 @@ def _get(self, route, data=None, headers=None, admin=False) -> Response: response = requests.get(full_url, params=data, cookies=cookies, headers=headers, timeout=DEFAULT_SOCKET_TIMEOUT) return response - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: full_url = self.selenium_context.build_url(f"api/{route}", for_selenium=False) cookies = None if admin: full_url = f"{full_url}?key={self._mixin_admin_api_key}" - else: + elif not anon: cookies = self.selenium_context.selenium_to_requests_cookies() request_kwd = prepare_request_params(data=data, files=files, as_json=json, headers=headers, cookies=cookies) response = requests.post(full_url, timeout=DEFAULT_SOCKET_TIMEOUT, **request_kwd) diff --git a/test/unit/app/managers/test_landing.py b/test/unit/app/managers/test_landing.py new file mode 100644 index 000000000000..d55294b599d1 --- /dev/null +++ b/test/unit/app/managers/test_landing.py @@ -0,0 +1,102 @@ +from uuid import uuid4 + +from pydantic import UUID4 + +from galaxy.exceptions import ( + InsufficientPermissionsException, + ItemAlreadyClaimedException, + ObjectNotFound, +) +from galaxy.managers.landing import LandingRequestManager +from galaxy.schema.schema import ( + ClaimLandingPayload, + CreateToolLandingRequestPayload, + CreateWorkflowLandingRequestPayload, + LandingRequestState, + ToolLandingRequest, + WorkflowLandingRequest, +) +from .base import BaseTestCase + +TEST_TOOL_ID = "cat1" +TEST_TOOL_VERSION = "1.0.0" +TEST_STATE = { + "input1": { + "src": "url", + "url": "https://raw.githubusercontent.com/galaxyproject/planemo/7be1bf5b3971a43eaa73f483125bfb8cabf1c440/tests/data/hello.txt", + "ext": "txt", + }, +} +CLIENT_SECRET = "mycoolsecret" + + +class TestLanding(BaseTestCase): + + def setUp(self): + super().setUp() + self.landing_manager = LandingRequestManager(self.trans.sa_session) + + def test_tool_landing_requests_typical_workflow(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + assert landing_request.state == LandingRequestState.UNCLAIMED + assert landing_request.uuid is not None + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret=CLIENT_SECRET) + landing_request = self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + landing_request = self.landing_manager.get_tool_landing_request(self.trans, uuid) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + + def test_tool_landing_requests_requires_matching_client_secret(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret="wrongsecret") + exception = None + try: + self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + except InsufficientPermissionsException as e: + exception = e + assert exception is not None + + def test_tool_landing_requests_get_requires_claim(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + uuid = landing_request.uuid + exception = None + try: + self.landing_manager.get_tool_landing_request(self.trans, uuid) + except InsufficientPermissionsException as e: + exception = e + assert exception is not None + + def test_cannot_reclaim_tool_landing(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + assert landing_request.state == LandingRequestState.UNCLAIMED + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret=CLIENT_SECRET) + landing_request = self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + assert landing_request.state == LandingRequestState.CLAIMED + exception = None + try: + self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + except ItemAlreadyClaimedException as e: + exception = e + assert exception + + def test_get_unknown_claim(self): + exception = None + try: + self.landing_manager.get_tool_landing_request(self.trans, uuid4()) + except ObjectNotFound as e: + exception = e + assert exception + + @property + def _tool_request(self) -> CreateToolLandingRequestPayload: + return CreateToolLandingRequestPayload( + tool_id=TEST_TOOL_ID, + tool_version=TEST_TOOL_VERSION, + request_state=TEST_STATE.copy(), + client_secret=CLIENT_SECRET, + )