Skip to content

Commit 47f9617

Browse files
committed
Add PUT request support at /api/jobs/{job_id}/files to FastAPIJobFiles
This path already supports GET, HEAD and POST requests; add support for PUT requests. There is a significant difference in behavior between POST and PUT requests: - POST requests take `path` and `job_key` both as query parameters or as body parameters belonging to a multipart request. PUT requests take them only as query parameters (just like GET and HEAD). - POST requests submit a file as one of the fields of the multipart request, whereas the submitted file is the whole body of the request for PUT requests. - POST requests can append to the `tool_stdout` and `tool_stderr`, PUT requests can only create new files or overwrite whole files. - POST requests support resumable uploads but PUT requests do not. - POST requests take the form parameters `__file_path` (path of a file uploaded via the nginx upload module) and `__file` but PUT requests do not.
1 parent 071d2d5 commit 47f9617

File tree

5 files changed

+172
-1
lines changed

5 files changed

+172
-1
lines changed

client/src/api/schema/schema.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3012,7 +3012,8 @@ export interface paths {
30123012
* This API method is intended only for consumption by job runners, not end users.
30133013
*/
30143014
get: operations["index_api_jobs__job_id__files_get"];
3015-
put?: never;
3015+
/** Populate an output file. */
3016+
put: operations["populate_api_jobs__job_id__files_put"];
30163017
/**
30173018
* Populate an output file.
30183019
* @description Populate an output file (formal dataset, task split part, working directory file (such as those related to
@@ -31286,6 +31287,76 @@ export interface operations {
3128631287
};
3128731288
};
3128831289
};
31290+
populate_api_jobs__job_id__files_put: {
31291+
parameters: {
31292+
query: {
31293+
/** @description Path to file to create/replace. */
31294+
path: string;
31295+
/** @description A key used to authenticate this request as acting on behalf of a job runner for the specified job. */
31296+
job_key: string;
31297+
};
31298+
header?: {
31299+
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
31300+
"run-as"?: string | null;
31301+
};
31302+
path: {
31303+
/** @description Encoded id string of the job. */
31304+
job_id: string;
31305+
};
31306+
cookie?: never;
31307+
};
31308+
requestBody?: never;
31309+
responses: {
31310+
/** @description Successful Response */
31311+
200: {
31312+
headers: {
31313+
[name: string]: unknown;
31314+
};
31315+
content: {
31316+
"application/json": unknown;
31317+
};
31318+
};
31319+
/** @description A new file has been created. */
31320+
201: {
31321+
headers: {
31322+
[name: string]: unknown;
31323+
};
31324+
content?: never;
31325+
};
31326+
/** @description An existing file has been replaced. */
31327+
204: {
31328+
headers: {
31329+
[name: string]: unknown;
31330+
};
31331+
content?: never;
31332+
};
31333+
/** @description Bad request. */
31334+
400: {
31335+
headers: {
31336+
[name: string]: unknown;
31337+
};
31338+
content?: never;
31339+
};
31340+
/** @description Request Error */
31341+
"4XX": {
31342+
headers: {
31343+
[name: string]: unknown;
31344+
};
31345+
content: {
31346+
"application/json": components["schemas"]["MessageExceptionModel"];
31347+
};
31348+
};
31349+
/** @description Server Error */
31350+
"5XX": {
31351+
headers: {
31352+
[name: string]: unknown;
31353+
};
31354+
content: {
31355+
"application/json": components["schemas"]["MessageExceptionModel"];
31356+
};
31357+
};
31358+
};
31359+
};
3128931360
create_api_jobs__job_id__files_post: {
3129031361
parameters: {
3129131362
query?: {

lib/galaxy/webapps/galaxy/api/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ def __init__(self, request: Request):
242242
def base(self) -> str:
243243
return str(self.__request.base_url)
244244

245+
def stream(self) -> AsyncGenerator:
246+
return self.__request.stream()
247+
245248
@property
246249
def url_path(self) -> str:
247250
scope = self.__request.scope
@@ -250,6 +253,10 @@ def url_path(self) -> str:
250253
url = urljoin(url, root_path)
251254
return url
252255

256+
@property
257+
def url(self) -> str:
258+
return str(self.__request.url)
259+
253260
@property
254261
def host(self) -> str:
255262
return self.__request.base_url.netloc

lib/galaxy/webapps/galaxy/api/job_files.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
API for asynchronous job running mechanisms can use to fetch or put files related to running and queued jobs.
33
"""
44

5+
import asyncio
56
import logging
67
import os
78
import re
@@ -19,6 +20,7 @@
1920
Path,
2021
Query,
2122
Request,
23+
Response,
2224
UploadFile,
2325
)
2426
from fastapi.params import Depends
@@ -36,6 +38,7 @@
3638
DependsOnTrans,
3739
Router,
3840
)
41+
from galaxy.work.context import SessionRequestContext
3942
from . import BaseGalaxyAPIController
4043

4144
__all__ = ("FastAPIJobFiles", "JobFilesAPIController", "router")
@@ -179,6 +182,63 @@ def index(
179182

180183
return GalaxyFileResponse(path)
181184

185+
# The ARC remote job runner (`lib.galaxy.jobs.runners.pulsar.PulsarARCJobRunner`) expects a `PUT` endpoint to stage
186+
# out result files back to Galaxy.
187+
@router.put(
188+
"/api/jobs/{job_id}/files",
189+
summary="Populate an output file.",
190+
responses={
191+
201: {"description": "A new file has been created."},
192+
204: {"description": "An existing file has been replaced."},
193+
400: {"description": "Bad request."},
194+
},
195+
)
196+
def populate(
197+
self,
198+
job_id: Annotated[str, Path(description="Encoded id string of the job.")],
199+
path: Annotated[str, Query(description="Path to file to create/replace.")],
200+
job_key: Annotated[
201+
str,
202+
Query(
203+
description=(
204+
"A key used to authenticate this request as acting on behalf of a job runner for the specified job."
205+
),
206+
),
207+
],
208+
trans: SessionRequestContext = DependsOnTrans,
209+
):
210+
path = unquote(path)
211+
212+
job = self.__authorize_job_access(trans, job_id, path=path, job_key=job_key)
213+
self.__check_job_can_write_to_path(trans, job, path)
214+
215+
destination_file_exists = os.path.exists(path)
216+
217+
# FastAPI can only read the file contents from the request body in an async context. To write the file without
218+
# using an async endpoint, the async code that reads the file from the body and writes it to disk will have to
219+
# run within the sync endpoint. Since the code that writes the data to disk is blocking
220+
# `destination_file.write(chunk)`, it has to run on its own event loop within the thread spawned to answer the
221+
# request to the sync endpoint.
222+
async def write():
223+
with open(path, "wb") as destination_file:
224+
async for chunk in trans.request.stream():
225+
destination_file.write(chunk)
226+
227+
target_dir = os.path.dirname(path)
228+
util.safe_makedirs(target_dir)
229+
event_loop = asyncio.new_event_loop()
230+
try:
231+
asyncio.set_event_loop(event_loop)
232+
event_loop.run_until_complete(write())
233+
finally:
234+
event_loop.close()
235+
236+
return (
237+
Response(status_code=201, headers={"Location": str(trans.request.url)})
238+
if not destination_file_exists
239+
else Response(status_code=204)
240+
)
241+
182242
@router.post(
183243
"/api/jobs/{job_id}/files",
184244
summary="Populate an output file.",

lib/galaxy/work/context.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import abc
22
from typing import (
33
Any,
4+
AsyncGenerator,
45
Dict,
56
List,
67
Optional,
@@ -87,11 +88,20 @@ class GalaxyAbstractRequest:
8788
def base(self) -> str:
8889
"""Base URL of the request."""
8990

91+
@abc.abstractmethod
92+
def stream(self) -> AsyncGenerator:
93+
"""Request body split in parts."""
94+
9095
@property
9196
@abc.abstractmethod
9297
def url_path(self) -> str:
9398
"""Base with optional prefix added."""
9499

100+
@property
101+
@abc.abstractmethod
102+
def url(self):
103+
"""URL of the request."""
104+
95105
@property
96106
@abc.abstractmethod
97107
def host(self) -> str:

test/integration/test_job_files.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,29 @@ def test_write_with_underscored_file_param(self):
251251
api_asserts.assert_status_code_is_ok(response)
252252
assert open(path).read() == "some initial text data"
253253

254+
def test_write_with_put_request(self):
255+
job, output_hda, working_directory = self.create_static_job_with_state("running")
256+
job_id, job_key = self._api_job_keys(job)
257+
path = self._app.object_store.get_filename(output_hda.dataset)
258+
assert path
259+
data = {"path": path, "job_key": job_key}
260+
261+
new_file_path = os.path.join(working_directory, "new_file.txt")
262+
put_url = self._api_url(f"jobs/{job_id}/files", use_key=False)
263+
response = requests.put(
264+
put_url,
265+
params={"path": new_file_path, "job_key": job_key},
266+
data=b"whole contents of the file",
267+
)
268+
assert response.status_code == 201
269+
assert open(new_file_path).read() == "whole contents of the file"
270+
271+
assert os.path.exists(path)
272+
put_url = self._api_url(f"jobs/{job_id}/files", use_key=False)
273+
response = requests.put(put_url, params=data, data=b"contents of a replacement file")
274+
assert response.status_code == 204
275+
assert open(path).read() == "contents of a replacement file"
276+
254277
def test_write_protection(self):
255278
job, _, _ = self.create_static_job_with_state("running")
256279
job_id, job_key = self._api_job_keys(job)

0 commit comments

Comments
 (0)