diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 23baa4dc094e..0a748e04031c 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -3047,7 +3047,7 @@ export interface paths { * @deprecated * @description This endpoint is deprecated. Please use POST /api/folders/{folder_id} or POST /api/folders/{folder_id}/contents instead. */ - post: operations["create_api_libraries__library_id__contents_post"]; + post: operations["create_form_api_libraries__library_id__contents_post"]; delete?: never; options?: never; head?: never; @@ -6079,6 +6079,70 @@ export interface components { /** Name */ name?: unknown; }; + /** Body_create_form_api_libraries__library_id__contents_post */ + Body_create_form_api_libraries__library_id__contents_post: { + /** Create Type */ + create_type: unknown; + /** + * Dbkey + * @default ? + */ + dbkey: unknown; + /** Extended Metadata */ + extended_metadata?: unknown; + /** File Type */ + file_type?: unknown; + /** Files */ + files?: string[] | null; + /** + * Filesystem Paths + * @default + */ + filesystem_paths: unknown; + /** Folder Id */ + folder_id: unknown; + /** From Hda Id */ + from_hda_id?: unknown; + /** From Hdca Id */ + from_hdca_id?: unknown; + /** + * Ldda Message + * @default + */ + ldda_message: unknown; + /** + * Link Data Only + * @default copy_files + */ + link_data_only: unknown; + /** + * Roles + * @default + */ + roles: unknown; + /** + * Server Dir + * @default + */ + server_dir: unknown; + /** + * Tag Using Filenames + * @default false + */ + tag_using_filenames: unknown; + /** + * Tags + * @default [] + */ + tags: unknown; + /** + * Upload Option + * @default upload_file + */ + upload_option: unknown; + /** Uuid */ + uuid?: unknown; + }; /** Body_fetch_form_api_tools_fetch_post */ Body_fetch_form_api_tools_fetch_post: { /** Files */ @@ -12876,7 +12940,7 @@ export interface components { * if True, copy the elements into the collection * @default false */ - copy_elements: boolean | null; + copy_elements: boolean; /** the type of item to create */ create_type: components["schemas"]["CreateType"]; /** list of dictionaries containing the element identifiers for the collection */ @@ -12896,29 +12960,29 @@ export interface components { * if True, hide the source items in the collection * @default false */ - hide_source_items: boolean | null; + hide_source_items: boolean; /** * the new message attribute of the LDDA created * @default */ - ldda_message: string | null; + ldda_message: string; /** the name of the collection */ name?: string | null; /** * create tags on datasets using the file's original name * @default false */ - tag_using_filenames: boolean | null; + tag_using_filenames: boolean; /** * create the given list of tags on datasets * @default [] */ - tags: string[] | null; + tags: string[]; /** * the method to use for uploading files * @default upload_file */ - upload_option: components["schemas"]["UploadOption"] | null; + upload_option: components["schemas"]["UploadOption"]; }; /** LibraryContentsCreateDatasetCollectionResponse */ LibraryContentsCreateDatasetCollectionResponse: components["schemas"]["LibraryContentsCreateDatasetResponse"][]; @@ -13004,7 +13068,7 @@ export interface components { * if True, purge the library dataset * @default false */ - purge: boolean | null; + purge: boolean; }; /** LibraryContentsDeleteResponse */ LibraryContentsDeleteResponse: { @@ -13024,7 +13088,7 @@ export interface components { * database key * @default ? */ - dbkey: string | unknown[] | null; + dbkey: string | unknown[]; /** sub-dictionary containing any extended metadata to associate with the item */ extended_metadata?: Record | null; /** file type */ @@ -13033,7 +13097,7 @@ export interface components { * (only if upload_option is 'upload_paths' and the user is an admin) file paths on the Galaxy server to upload to the library, one file per line * @default */ - filesystem_paths: string | null; + filesystem_paths: string; /** * the encoded id of the parent folder of the new item * @example 0123456789ABCDEF @@ -13047,39 +13111,41 @@ export interface components { * the new message attribute of the LDDA created * @default */ - ldda_message: string | null; + ldda_message: string; /** * (only when upload_option is 'upload_directory' or 'upload_paths').Setting to 'link_to_files' symlinks instead of copying the files * @default copy_files */ - link_data_only: components["schemas"]["LinkDataOnly"] | null; + link_data_only: components["schemas"]["LinkDataOnly"]; /** * user selected roles * @default */ - roles: string | null; + roles: string; /** * (only if upload_option is 'upload_directory') relative path of the subdirectory of Galaxy ``library_import_dir`` (if admin) or ``user_library_import_dir`` (if non-admin) to upload. All and only the files (i.e. no subdirectories) contained in the specified directory will be uploaded. * @default */ - server_dir: string | null; + server_dir: string; /** * create tags on datasets using the file's original name * @default false */ - tag_using_filenames: boolean | null; + tag_using_filenames: boolean; /** * create the given list of tags on datasets * @default [] */ - tags: string[] | null; + tags: string[]; /** * the method to use for uploading files * @default upload_file */ - upload_option: components["schemas"]["UploadOption"] | null; + upload_option: components["schemas"]["UploadOption"]; /** UUID of the dataset to upload */ uuid?: string | null; + } & { + [key: string]: unknown; }; /** LibraryContentsFolderCreatePayload */ LibraryContentsFolderCreatePayload: { @@ -13089,7 +13155,7 @@ export interface components { * description of the folder to create * @default */ - description: string | null; + description: string; /** sub-dictionary containing any extended metadata to associate with the item */ extended_metadata?: Record | null; /** @@ -13105,27 +13171,27 @@ export interface components { * the new message attribute of the LDDA created * @default */ - ldda_message: string | null; + ldda_message: string; /** * name of the folder to create * @default */ - name: string | null; + name: string; /** * create tags on datasets using the file's original name * @default false */ - tag_using_filenames: boolean | null; + tag_using_filenames: boolean; /** * create the given list of tags on datasets * @default [] */ - tags: string[] | null; + tags: string[]; /** * the method to use for uploading files * @default upload_file */ - upload_option: components["schemas"]["UploadOption"] | null; + upload_option: components["schemas"]["UploadOption"]; }; /** LibraryContentsIndexDatasetResponse */ LibraryContentsIndexDatasetResponse: { @@ -27929,7 +27995,7 @@ export interface operations { }; }; }; - create_api_libraries__library_id__contents_post: { + create_form_api_libraries__library_id__contents_post: { parameters: { query?: never; header?: { @@ -27943,10 +28009,7 @@ export interface operations { }; requestBody: { content: { - "application/json": - | components["schemas"]["LibraryContentsFolderCreatePayload"] - | components["schemas"]["LibraryContentsFileCreatePayload"] - | components["schemas"]["LibraryContentsCollectionCreatePayload"]; + "multipart/form-data": components["schemas"]["Body_create_form_api_libraries__library_id__contents_post"]; }; }; responses: { diff --git a/lib/galaxy/actions/library.py b/lib/galaxy/actions/library.py index a10098da9400..439953dfe467 100644 --- a/lib/galaxy/actions/library.py +++ b/lib/galaxy/actions/library.py @@ -125,6 +125,8 @@ def _upload_dataset(self, trans, folder_id: int, payload): raise exceptions.InvalidFileFormatError("Invalid folder specified") # Proceed with (mostly) regular upload processing if we're still errorless if payload.upload_option == "upload_file": + for i, upload_dataset in enumerate(tool_params["files"]): + upload_dataset["file_data"] = payload.files[i] tool_params = upload_common.persist_uploads(tool_params, trans) uploaded_datasets = upload_common.get_uploaded_datasets( trans, cntrller, tool_params, dataset_upload_inputs, library_bunch=library_bunch diff --git a/lib/galaxy/schema/library_contents.py b/lib/galaxy/schema/library_contents.py index c692c062da26..44d3e8191619 100644 --- a/lib/galaxy/schema/library_contents.py +++ b/lib/galaxy/schema/library_contents.py @@ -1,3 +1,4 @@ +import json from enum import Enum from typing import ( Any, @@ -12,6 +13,7 @@ Field, RootModel, ) +from pydantic.functional_validators import field_validator from typing_extensions import ( Annotated, Literal, @@ -52,7 +54,7 @@ class LibraryContentsCreatePayload(Model): ..., title="the type of item to create", ) - upload_option: Optional[UploadOption] = Field( + upload_option: UploadOption = Field( UploadOption.upload_file, title="the method to use for uploading files", ) @@ -60,11 +62,11 @@ class LibraryContentsCreatePayload(Model): ..., title="the encoded id of the parent folder of the new item", ) - tag_using_filenames: Optional[bool] = Field( + tag_using_filenames: bool = Field( False, title="create tags on datasets using the file's original name", ) - tags: Optional[List[str]] = Field( + tags: List[str] = Field( [], title="create the given list of tags on datasets", ) @@ -76,7 +78,7 @@ class LibraryContentsCreatePayload(Model): None, title="(only if create_type is 'file') the encoded id of an accessible HDCA to copy into the library", ) - ldda_message: Optional[str] = Field( + ldda_message: str = Field( "", title="the new message attribute of the LDDA created", ) @@ -85,13 +87,20 @@ class LibraryContentsCreatePayload(Model): title="sub-dictionary containing any extended metadata to associate with the item", ) + @field_validator("tags", mode="before", check_fields=False) + @classmethod + def tags_string_to_json(cls, v): + if isinstance(v, str): + return json.loads(v) + return v + class LibraryContentsFileCreatePayload(LibraryContentsCreatePayload): - dbkey: Optional[Union[str, list]] = Field( + dbkey: Union[str, list] = Field( "?", title="database key", ) - roles: Optional[str] = Field( + roles: str = Field( "", title="user selected roles", ) @@ -99,7 +108,7 @@ class LibraryContentsFileCreatePayload(LibraryContentsCreatePayload): None, title="file type", ) - server_dir: Optional[str] = Field( + server_dir: str = Field( "", title="(only if upload_option is 'upload_directory') relative path of the " "subdirectory of Galaxy ``library_import_dir`` (if admin) or " @@ -107,12 +116,12 @@ class LibraryContentsFileCreatePayload(LibraryContentsCreatePayload): "All and only the files (i.e. no subdirectories) contained " "in the specified directory will be uploaded.", ) - filesystem_paths: Optional[str] = Field( + filesystem_paths: str = Field( "", title="(only if upload_option is 'upload_paths' and the user is an admin) " "file paths on the Galaxy server to upload to the library, one file per line", ) - link_data_only: Optional[LinkDataOnly] = Field( + link_data_only: LinkDataOnly = Field( LinkDataOnly.copy_files, title="(only when upload_option is 'upload_directory' or 'upload_paths')." "Setting to 'link_to_files' symlinks instead of copying the files", @@ -122,13 +131,16 @@ class LibraryContentsFileCreatePayload(LibraryContentsCreatePayload): title="UUID of the dataset to upload", ) + # uploaded file fields + model_config = ConfigDict(extra="allow") + class LibraryContentsFolderCreatePayload(LibraryContentsCreatePayload): - name: Optional[str] = Field( + name: str = Field( "", title="name of the folder to create", ) - description: Optional[str] = Field( + description: str = Field( "", title="description of the folder to create", ) @@ -147,11 +159,11 @@ class LibraryContentsCollectionCreatePayload(LibraryContentsCreatePayload): None, title="the name of the collection", ) - hide_source_items: Optional[bool] = Field( + hide_source_items: bool = Field( False, title="if True, hide the source items in the collection", ) - copy_elements: Optional[bool] = Field( + copy_elements: bool = Field( False, title="if True, copy the elements into the collection", ) @@ -165,7 +177,7 @@ class LibraryContentsUpdatePayload(Model): class LibraryContentsDeletePayload(Model): - purge: Optional[bool] = Field( + purge: bool = Field( False, title="if True, purge the library dataset", ) diff --git a/lib/galaxy/webapps/galaxy/api/library_contents.py b/lib/galaxy/webapps/galaxy/api/library_contents.py index 566744599fae..53a03dc3f3af 100644 --- a/lib/galaxy/webapps/galaxy/api/library_contents.py +++ b/lib/galaxy/webapps/galaxy/api/library_contents.py @@ -3,12 +3,22 @@ """ import logging +import shutil +import tempfile from typing import ( + List, Optional, Union, ) -from fastapi import Body +from fastapi import ( + Body, + Depends, + Request, + UploadFile, +) +from pydantic import Json +from starlette.datastructures import UploadFile as StarletteUploadFile from galaxy.managers.context import ( ProvidesHistoryContext, @@ -41,12 +51,27 @@ LibraryContentsService, MaybeLibraryFolderOrDatasetID, ) +from . import ( + APIContentTypeRoute, + as_form, +) log = logging.getLogger(__name__) router = Router(tags=["libraries"]) +class FormDataApiRoute(APIContentTypeRoute): + match_content_type = "multipart/form-data" + + +class JsonApiRoute(APIContentTypeRoute): + match_content_type = "application/json" + + +LibraryContentsCreateForm = as_form(LibraryContentsFileCreatePayload) + + @router.cbv class FastAPILibraryContents: service: LibraryContentsService = depends(LibraryContentsService) @@ -90,13 +115,40 @@ def show( "/api/libraries/{library_id}/contents", summary="Create a new library file or folder.", deprecated=True, + route_class_override=JsonApiRoute, ) - def create( + def create_json( self, library_id: Union[DecodedDatabaseIdField, LibraryFolderDatabaseIdField], payload: Union[ LibraryContentsFolderCreatePayload, LibraryContentsFileCreatePayload, LibraryContentsCollectionCreatePayload - ], + ] = Body(...), + trans: ProvidesHistoryContext = DependsOnTrans, + ) -> Union[ + LibraryContentsCreateFolderListResponse, + LibraryContentsCreateFileListResponse, + LibraryContentsCreateDatasetCollectionResponse, + LibraryContentsCreateDatasetResponse, + ]: + """ + This endpoint is deprecated. Please use POST /api/folders/{folder_id} or POST /api/folders/{folder_id}/contents instead. + """ + return self.service.create(trans, library_id, payload) + + @router.post( + "/api/libraries/{library_id}/contents", + summary="Create a new library file or folder.", + deprecated=True, + route_class_override=FormDataApiRoute, + ) + async def create_form( + self, + request: Request, + library_id: Union[DecodedDatabaseIdField, LibraryFolderDatabaseIdField], + payload: Union[Json[LibraryContentsFileCreatePayload], LibraryContentsFileCreatePayload] = Depends( + LibraryContentsCreateForm.as_form + ), + files: Optional[List[UploadFile]] = None, trans: ProvidesHistoryContext = DependsOnTrans, ) -> Union[ LibraryContentsCreateFolderListResponse, @@ -107,6 +159,20 @@ def create( """ This endpoint is deprecated. Please use POST /api/folders/{folder_id} or POST /api/folders/{folder_id}/contents instead. """ + # FastAPI's UploadFile is a very light wrapper around starlette's UploadFile + if not files: + data = await request.form() + upload_files = [] + for i, upload_file in enumerate(data.values()): + if isinstance(upload_file, StarletteUploadFile): + with tempfile.NamedTemporaryFile( + dir=trans.app.config.new_file_path, prefix="upload_file_data_", delete=False + ) as dest: + shutil.copyfileobj(upload_file.file, dest) # type: ignore[misc] # https://github.com/python/mypy/issues/15031 + upload_file.file.close() + upload_files.append(dict(filename=upload_file.filename, local_filename=dest.name)) + setattr(payload, "files", upload_files) + return self.service.create(trans, library_id, payload) @router.put( diff --git a/lib/galaxy_test/api/test_tags.py b/lib/galaxy_test/api/test_tags.py index 035f675aecb5..cdce0a0705af 100644 --- a/lib/galaxy_test/api/test_tags.py +++ b/lib/galaxy_test/api/test_tags.py @@ -114,7 +114,9 @@ def _get_item(self, item_id: str): def test_upload_file_contents_with_tags(self): initial_tags = ["name:foobar", "barfoo"] - ld = self.library_populator.new_library_dataset(name=f"test-library-dataset-{uuid4()}", tags=initial_tags) + ld = self.library_populator.new_library_dataset( + name=f"test-library-dataset-{uuid4()}", tags=json.dumps(initial_tags) + ) assert ld["tags"] == initial_tags diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index abd8fe1b5a24..82b458d0068e 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -2740,7 +2740,7 @@ def raw_library_contents_create(self, library_id, payload, files=None): files = {} url_rel = f"libraries/{library_id}/contents" - return self.galaxy_interactor.post(url_rel, payload, files=files, json=True) + return self.galaxy_interactor.post(url_rel, payload, files=files) def show_ld_raw(self, library_id: str, library_dataset_id: str) -> Response: response = self.galaxy_interactor.get(f"libraries/{library_id}/contents/{library_dataset_id}") @@ -2759,9 +2759,7 @@ def show_ldda(self, ldda_id): def new_library_dataset_in_private_library(self, library_name="private_dataset", wait=True): library = self.new_private_library(library_name) payload, files = self.create_dataset_request(library, file_type="txt", contents="create_test") - create_response = self.galaxy_interactor.post( - f"libraries/{library['id']}/contents", payload, files=files, json=True - ) + create_response = self.galaxy_interactor.post(f"libraries/{library['id']}/contents", payload, files=files) api_asserts.assert_status_code_is(create_response, 200) library_datasets = create_response.json() assert len(library_datasets) == 1