From 9bf86543e5045ee41caf0739461df613700f4222 Mon Sep 17 00:00:00 2001 From: lanzhiwang Date: Sat, 6 May 2023 21:01:05 +0800 Subject: [PATCH] add doownload file feature Signed-off-by: lanzhiwang --- jupyterlab_s3_browser/_version.py | 3 +- jupyterlab_s3_browser/handlers.py | 112 +++++++++++++------ jupyterlab_s3_browser/tests/test_get_s3.py | 21 ++-- setup.py | 14 +-- src/contents.ts | 120 ++++++++++----------- src/s3.ts | 72 ++++++------- 6 files changed, 196 insertions(+), 146 deletions(-) diff --git a/jupyterlab_s3_browser/_version.py b/jupyterlab_s3_browser/_version.py index 216ca7b..78f9c53 100644 --- a/jupyterlab_s3_browser/_version.py +++ b/jupyterlab_s3_browser/_version.py @@ -17,8 +17,7 @@ def _fetchVersion(): pass raise FileNotFoundError( # noqa: F821 - "Could not find package.json under dir {}".format(HERE) - ) + "Could not find package.json under dir {}".format(HERE)) __version__ = _fetchVersion() diff --git a/jupyterlab_s3_browser/handlers.py b/jupyterlab_s3_browser/handlers.py index 958e890..3d6b6dd 100644 --- a/jupyterlab_s3_browser/handlers.py +++ b/jupyterlab_s3_browser/handlers.py @@ -4,19 +4,20 @@ import base64 import json import logging -from pathlib import Path import boto3 -import s3fs import tornado from botocore.exceptions import NoCredentialsError from jupyter_server.base.handlers import APIHandler from jupyter_server.utils import url_path_join +from pathlib import Path + +import s3fs +import boto3 class DirectoryNotEmptyException(Exception): """Raise for attempted deletions of non-empty directories""" - pass @@ -48,7 +49,7 @@ def create_s3_resource(config): ) else: - return boto3.resource("s3") + return boto3.resource('s3') def _test_aws_s3_role_access(): @@ -57,10 +58,11 @@ def _test_aws_s3_role_access(): """ test = boto3.resource("s3") all_buckets = test.buckets.all() - result = [ - {"name": bucket.name + "/", "path": bucket.name + "/", "type": "directory"} - for bucket in all_buckets - ] + result = [{ + "name": bucket.name + "/", + "path": bucket.name + "/", + "type": "directory" + } for bucket in all_buckets] return result @@ -79,8 +81,7 @@ def has_aws_s3_role_access(): access_key_id = line.split("=", 1)[1] # aws keys reliably start with AKIA for long-term or ASIA for short-term if not access_key_id.startswith( - "AKIA" - ) and not access_key_id.startswith("ASIA"): + "AKIA") and not access_key_id.startswith("ASIA"): # if any keys are not valid AWS keys, don't try to authenticate logging.info( "Found invalid AWS aws_access_key_id in ~/.aws/credentials file, " @@ -111,12 +112,11 @@ def test_s3_credentials(endpoint_url, client_id, client_secret, session_token): aws_session_token=session_token, ) all_buckets = test.buckets.all() - logging.debug( - [ - {"name": bucket.name + "/", "path": bucket.name + "/", "type": "directory"} - for bucket in all_buckets - ] - ) + logging.debug([{ + "name": bucket.name + "/", + "path": bucket.name + "/", + "type": "directory" + } for bucket in all_buckets]) class AuthHandler(APIHandler): # pylint: disable=abstract-method @@ -177,7 +177,8 @@ def post(self, path=""): client_secret = req["client_secret"] session_token = req["session_token"] - test_s3_credentials(endpoint_url, client_id, client_secret, session_token) + test_s3_credentials(endpoint_url, client_id, client_secret, + session_token) self.config.endpoint_url = endpoint_url self.config.client_id = client_id @@ -202,7 +203,51 @@ def convertS3FStoJupyterFormat(result): } -class S3Handler(APIHandler): +class FilesHandler(APIHandler): + """ + Handles requests for getting files (e.g. for downloading) + """ + + @property + def config(self): + return self.settings["s3_config"] + + @tornado.web.authenticated + def get(self, path=""): + """ + Takes a path and returns lists of files/objects + and directories/prefixes based on the path. + """ + path = path.removeprefix("/") + + try: + if not self.s3fs: + self.s3fs = create_s3fs(self.config) + + self.s3fs.invalidate_cache() + + with self.s3fs.open(path, "rb") as f: + result = f.read() + + except S3ResourceNotFoundException as e: + result = json.dumps({ + "error": + 404, + "message": + "The requested resource could not be found.", + }) + except Exception as e: + logging.error("Exception encountered during GET {}: {}".format( + path, e)) + result = json.dumps({"error": 500, "message": str(e)}) + + self.finish(result) + + s3fs = None + s3_resource = None + + +class ContentsHandler(APIHandler): """ Handles requests for getting S3 objects """ @@ -230,18 +275,18 @@ def get(self, path=""): self.s3fs.invalidate_cache() if (path and not path.endswith("/")) and ( - "X-Custom-S3-Is-Dir" not in self.request.headers + "X-Custom-S3-Is-Dir" not in self.request.headers ): # TODO: replace with function with self.s3fs.open(path, "rb") as f: result = { "path": path, "type": "file", - "content": base64.encodebytes(f.read()).decode("ascii"), + "content": + base64.encodebytes(f.read()).decode("ascii"), } else: raw_result = list( - map(convertS3FStoJupyterFormat, self.s3fs.listdir(path)) - ) + map(convertS3FStoJupyterFormat, self.s3fs.listdir(path))) result = list(filter(lambda x: x["name"] != "", raw_result)) except S3ResourceNotFoundException as e: @@ -250,7 +295,8 @@ def get(self, path=""): "message": "The requested resource could not be found.", } except Exception as e: - logging.error("Exception encountered during GET {}: {}".format(path, e)) + logging.error("Exception encountered during GET {}: {}".format( + path, e)) result = {"error": 500, "message": str(e)} self.finish(json.dumps(result)) @@ -283,7 +329,8 @@ def put(self, path=""): result = { "path": path, "type": "file", - "content": base64.encodebytes(f.read()).decode("ascii"), + "content": + base64.encodebytes(f.read()).decode("ascii"), } elif "X-Custom-S3-Move-Src" in self.request.headers: source = self.request.headers["X-Custom-S3-Move-Src"] @@ -295,7 +342,8 @@ def put(self, path=""): result = { "path": path, "type": "file", - "content": base64.encodebytes(f.read()).decode("ascii"), + "content": + base64.encodebytes(f.read()).decode("ascii"), } elif "X-Custom-S3-Is-Dir" in self.request.headers: path = path.lower() @@ -351,14 +399,12 @@ def delete(self, path=""): objects_matching_prefix = self.s3fs.listdir(path + "/") is_directory = (len(objects_matching_prefix) > 1) or ( (len(objects_matching_prefix) == 1) - and objects_matching_prefix[0]["Key"] != path - ) + and objects_matching_prefix[0]['Key'] != path) if is_directory: if (len(objects_matching_prefix) > 1) or ( (len(objects_matching_prefix) == 1) - and objects_matching_prefix[0]["Key"] != path + "/" - ): + and objects_matching_prefix[0]['Key'] != path + "/"): raise DirectoryNotEmptyException() else: # for some reason s3fs.rm doesn't work reliably @@ -393,7 +439,11 @@ def setup_handlers(web_app): base_url = web_app.settings["base_url"] handlers = [ - (url_path_join(base_url, "jupyterlab_s3_browser", "auth(.*)"), AuthHandler), - (url_path_join(base_url, "jupyterlab_s3_browser", "files(.*)"), S3Handler), + (url_path_join(base_url, "jupyterlab_s3_browser", + "auth(.*)"), AuthHandler), + (url_path_join(base_url, "jupyterlab_s3_browser", + "contents(.*)"), ContentsHandler), + (url_path_join(base_url, "jupyterlab_s3_browser", + "files(.*)"), FilesHandler), ] web_app.add_handlers(host_pattern, handlers) diff --git a/jupyterlab_s3_browser/tests/test_get_s3.py b/jupyterlab_s3_browser/tests/test_get_s3.py index b1f983e..2902e37 100644 --- a/jupyterlab_s3_browser/tests/test_get_s3.py +++ b/jupyterlab_s3_browser/tests/test_get_s3.py @@ -12,7 +12,11 @@ def test_get_single_bucket(): s3.create_bucket(Bucket=bucket_name) result = jupyterlab_s3_browser.get_s3_objects_from_path(s3, "/") - assert result == [{"name": bucket_name, "type": "directory", "path": bucket_name}] + assert result == [{ + "name": bucket_name, + "type": "directory", + "path": bucket_name + }] @mock_s3 @@ -24,10 +28,11 @@ def test_get_multiple_buckets(): s3.create_bucket(Bucket=bucket_name) result = jupyterlab_s3_browser.get_s3_objects_from_path(s3, "/") - expected_result = [ - {"name": bucket_name, "type": "directory", "path": bucket_name} - for bucket_name in bucket_names - ] + expected_result = [{ + "name": bucket_name, + "type": "directory", + "path": bucket_name + } for bucket_name in bucket_names] assert result == expected_result @@ -60,6 +65,6 @@ def test_get_files_inside_bucket(): }, ] print(result) - assert sorted(result, key=lambda i: i["name"]) == sorted( - expected_result, key=lambda i: i["name"] - ) + assert sorted(result, + key=lambda i: i["name"]) == sorted(expected_result, + key=lambda i: i["name"]) diff --git a/setup.py b/setup.py index 5022b2f..376b46c 100755 --- a/setup.py +++ b/setup.py @@ -37,7 +37,8 @@ data_files_spec = [ ("share/jupyter/labextensions/%s" % labext_name, str(lab_path), "**"), - ("share/jupyter/labextensions/%s" % labext_name, str(HERE), "install.json"), + ("share/jupyter/labextensions/%s" % labext_name, str(HERE), + "install.json"), ( "etc/jupyter/jupyter_server_config.d", "jupyter-config/jupyter_server_config.d", @@ -50,10 +51,9 @@ ), ] - -cmdclass = create_cmdclass( - "jsdeps", package_data_spec=package_data_spec, data_files_spec=data_files_spec -) +cmdclass = create_cmdclass("jsdeps", + package_data_spec=package_data_spec, + data_files_spec=data_files_spec) js_command = combine_commands( install_npm(HERE, build_cmd="build:prod", npm=["jlpm"]), @@ -99,7 +99,9 @@ "singleton-decorator", "jupyterlab>=2.0.0", ], - extras_require={"dev": ["jupyter_packaging~=0.7.9", "pytest", "moto", "coverage"]}, + extras_require={ + "dev": ["jupyter_packaging~=0.7.9", "pytest", "moto", "coverage"] + }, ) if __name__ == "__main__": diff --git a/src/contents.ts b/src/contents.ts index 75e9fbf..2de891b 100755 --- a/src/contents.ts +++ b/src/contents.ts @@ -1,16 +1,16 @@ -import { Signal, ISignal } from "@lumino/signaling"; +import { Signal, ISignal } from '@lumino/signaling'; -import { PathExt } from "@jupyterlab/coreutils"; +import { PathExt } from '@jupyterlab/coreutils'; -import { DocumentRegistry } from "@jupyterlab/docregistry"; +import { DocumentRegistry } from '@jupyterlab/docregistry'; -import { Contents, ServerConnection } from "@jupyterlab/services"; +import { Contents, ServerConnection } from '@jupyterlab/services'; -import * as base64js from "base64-js"; +import { URLExt } from '@jupyterlab/coreutils'; -import * as s3 from "./s3"; +import * as base64js from 'base64-js'; -import { Dialog, showDialog } from "@jupyterlab/apputils"; +import * as s3 from './s3'; /** * A Contents.IDrive implementation for s3-api-compatible object storage. @@ -31,8 +31,8 @@ export class S3Drive implements Contents.IDrive { /** * The name of the drive. */ - get name(): "S3" { - return "S3"; + get name(): 'S3' { + return 'S3'; } /** @@ -78,22 +78,22 @@ export class S3Drive implements Contents.IDrive { path: string, options?: Contents.IFetchOptions ): Promise { - if (options.type === "file" || options.type === "notebook") { + if (options.type === 'file' || options.type === 'notebook') { const s3Contents = await s3.read(path); const types = this._registry.getFileTypesForPath(path); const fileType = - types.length === 0 ? this._registry.getFileType("text")! : types[0]; + types.length === 0 ? this._registry.getFileType('text')! : types[0]; const mimetype = fileType.mimeTypes[0]; const format = fileType.fileFormat; let parsedContent; switch (format) { - case "text": + case 'text': parsedContent = Private.b64DecodeUTF8(s3Contents.content); break; - case "base64": + case 'base64': parsedContent = s3Contents.content; break; - case "json": + case 'json': parsedContent = JSON.parse(Private.b64DecodeUTF8(s3Contents.content)); break; default: @@ -101,15 +101,15 @@ export class S3Drive implements Contents.IDrive { } const contents: Contents.IModel = { - type: "file", + type: 'file', path, - name: "", + name: '', format, content: parsedContent, - created: "", + created: '', writable: true, - last_modified: "", - mimetype, + last_modified: '', + mimetype }; return contents; @@ -129,12 +129,8 @@ export class S3Drive implements Contents.IDrive { * path if necessary. */ async getDownloadUrl(path: string): Promise { - await showDialog({ - title: "Sorry", - body: "This feature is not yet implemented.", - buttons: [Dialog.cancelButton({ label: "Cancel" })], - }); - throw Error("Not yet implemented"); + const settings = ServerConnection.makeSettings(); + return URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/files', path); } /** @@ -149,7 +145,7 @@ export class S3Drive implements Contents.IDrive { options: Contents.ICreateOptions = {} ): Promise { let s3contents; - const basename = "untitled"; + const basename = 'untitled'; let filename = basename; const existingFiles = await s3.ls(options.path); const existingFilenames = existingFiles.content.map( @@ -161,21 +157,21 @@ export class S3Drive implements Contents.IDrive { filename = basename + uniqueSuffix; } switch (options.type) { - case "file": - s3contents = await s3.writeFile(options.path + "/" + filename, ""); + case 'file': + s3contents = await s3.writeFile(options.path + '/' + filename, ''); break; - case "directory": - if (options.path === "") { - throw new Error("Bucket creation is not currently supported."); + case 'directory': + if (options.path === '') { + throw new Error('Bucket creation is not currently supported.'); } - s3contents = await s3.createDirectory(options.path + "/" + filename); + s3contents = await s3.createDirectory(options.path + '/' + filename); break; default: throw new Error(`Unexpected type: ${options.type}`); } const types = this._registry.getFileTypesForPath(s3contents.path); const fileType = - types.length === 0 ? this._registry.getFileType("text")! : types[0]; + types.length === 0 ? this._registry.getFileType('text')! : types[0]; const mimetype = fileType.mimeTypes[0]; const format = fileType.fileFormat; const contents: Contents.IModel = { @@ -183,17 +179,17 @@ export class S3Drive implements Contents.IDrive { path: options.path, name: filename, format, - content: "", - created: "", + content: '', + created: '', writable: true, - last_modified: "", - mimetype, + last_modified: '', + mimetype }; this._fileChanged.emit({ - type: "new", + type: 'new', oldValue: null, - newValue: contents, + newValue: contents }); return contents; } @@ -207,15 +203,15 @@ export class S3Drive implements Contents.IDrive { */ async delete(path: string): Promise { const deletionRequest = await s3.deleteFile(path); - if (deletionRequest.error && deletionRequest.error === "DIR_NOT_EMPTY") { + if (deletionRequest.error && deletionRequest.error === 'DIR_NOT_EMPTY') { throw new Error( `${path} is not empty. Deletion of non-empty directories is not currently supported.` ); } this._fileChanged.emit({ - type: "delete", + type: 'delete', oldValue: { path }, - newValue: null, + newValue: null }); } @@ -230,14 +226,14 @@ export class S3Drive implements Contents.IDrive { * the file is renamed. */ async rename(path: string, newPath: string): Promise { - if (!path.includes("/")) { - throw Error("Renaming of buckets is not currently supported."); + if (!path.includes('/')) { + throw Error('Renaming of buckets is not currently supported.'); } const content = await s3.moveFile(path, newPath); this._fileChanged.emit({ - type: "rename", + type: 'rename', oldValue: { path }, - newValue: content, + newValue: content }); return content; } @@ -257,15 +253,15 @@ export class S3Drive implements Contents.IDrive { options: Partial ): Promise { let content = options.content; - if (options.format === "base64") { + if (options.format === 'base64') { content = Private.b64DecodeUTF8(options.content); - } else if (options.format === "json") { + } else if (options.format === 'json') { content = JSON.stringify(options.content); } const s3contents = await s3.writeFile(path, content); const types = this._registry.getFileTypesForPath(s3contents.path); const fileType = - types.length === 0 ? this._registry.getFileType("text")! : types[0]; + types.length === 0 ? this._registry.getFileType('text')! : types[0]; const mimetype = fileType.mimeTypes[0]; const format = fileType.fileFormat; const contents: Contents.IModel = { @@ -274,16 +270,16 @@ export class S3Drive implements Contents.IDrive { name: options.name, format, content, - created: "", + created: '', writable: true, - last_modified: "", - mimetype, + last_modified: '', + mimetype }; this._fileChanged.emit({ - type: "save", + type: 'save', oldValue: null, - newValue: contents, + newValue: contents }); return contents; } @@ -299,15 +295,15 @@ export class S3Drive implements Contents.IDrive { * file is copied. */ async copy(fromFile: string, toDir: string): Promise { - let basename = PathExt.basename(fromFile).split(".")[0]; - basename += "-copy"; + let basename = PathExt.basename(fromFile).split('.')[0]; + basename += '-copy'; const ext = PathExt.extname(fromFile); - const name = "/" + toDir + "/" + basename + ext; + const name = '/' + toDir + '/' + basename + ext; const content = await s3.copyFile(fromFile, name); this._fileChanged.emit({ - type: "new", + type: 'new', oldValue: null, - newValue: content, + newValue: content }); return content; } @@ -346,7 +342,7 @@ export class S3Drive implements Contents.IDrive { * @returns A promise which resolves when the checkpoint is restored. */ async restoreCheckpoint(path: string, checkpointID: string): Promise { - throw Error("Not yet implemented"); + throw Error('Not yet implemented'); } /** @@ -374,7 +370,7 @@ namespace Private { /** * Decoder from bytes to UTF-8. */ - const decoder = new TextDecoder("utf8"); + const decoder = new TextDecoder('utf8'); /** * Decode a base-64 encoded string into unicode. @@ -382,7 +378,7 @@ namespace Private { * See https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding#Solution_2_%E2%80%93_rewrite_the_DOMs_atob()_and_btoa()_using_JavaScript's_TypedArrays_and_UTF-8 */ export function b64DecodeUTF8(str: string): string { - const bytes = base64js.toByteArray(str.replace(/\n/g, "")); + const bytes = base64js.toByteArray(str.replace(/\n/g, '')); return decoder.decode(bytes); } } diff --git a/src/s3.ts b/src/s3.ts index 58d89ea..e75bccc 100644 --- a/src/s3.ts +++ b/src/s3.ts @@ -2,9 +2,8 @@ // import { PathExt } from '@jupyterlab/coreutils'; -import { Contents, ServerConnection } from "@jupyterlab/services"; - -import { URLExt } from "@jupyterlab/coreutils"; +import { Contents, ServerConnection } from '@jupyterlab/services'; +import { URLExt } from '@jupyterlab/coreutils'; export async function copyFile( oldPath: string, @@ -14,8 +13,8 @@ export async function copyFile( const settings = ServerConnection.makeSettings(); // can be stored as class var const response = await ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", newPath), - { method: "PUT", headers: { "X-Custom-S3-Copy-Src": oldPath } }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', newPath), + { method: 'PUT', headers: { 'X-Custom-S3-Copy-Src': oldPath } }, settings ) ).json(); @@ -30,8 +29,8 @@ export async function moveFile( const settings = ServerConnection.makeSettings(); // can be stored as class var const response = await ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", newPath), - { method: "PUT", headers: { "X-Custom-S3-Move-Src": oldPath } }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', newPath), + { method: 'PUT', headers: { 'X-Custom-S3-Move-Src': oldPath } }, settings ) ).json(); @@ -43,8 +42,8 @@ export async function deleteFile(path: string): Promise { const settings = ServerConnection.makeSettings(); // can be stored as class var const response = await ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", path), - { method: "DELETE" }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', path), + { method: 'DELETE' }, settings ) ).json(); @@ -59,8 +58,8 @@ export async function writeFile( const settings = ServerConnection.makeSettings(); // can be stored as class var const response = await ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", path), - { method: "PUT", body: JSON.stringify({ content }) }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', path), + { method: 'PUT', body: JSON.stringify({ content }) }, settings ) ).json(); @@ -71,22 +70,22 @@ export async function createDirectory(path: string): Promise { const settings = ServerConnection.makeSettings(); // can be stored as class var await ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", path), - { method: "PUT", headers: { "X-Custom-S3-Is-Dir": "true" } }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', path), + { method: 'PUT', headers: { 'X-Custom-S3-Is-Dir': 'true' } }, settings ) ).json(); return { - type: "directory", + type: 'directory', path: path.trim(), - name: "Untitled", - format: "json", + name: 'Untitled', + format: 'json', content: [], - created: "", + created: '', writable: true, - last_modified: "", - mimetype: "", + last_modified: '', + mimetype: '' }; // return await ls(path); } @@ -98,8 +97,8 @@ export async function get( const settings = ServerConnection.makeSettings(); // can be stored as class var const response = await ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", path), - { method: "GET" }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', path), + { method: 'GET' }, settings ) ).json(); @@ -110,13 +109,13 @@ function s3ToJupyterContents(s3Content: any): Contents.IModel { const result = { name: s3Content.name, path: s3Content.path, - format: "json", // this._registry.getFileType('text').fileFormat, + format: 'json', // this._registry.getFileType('text').fileFormat, type: s3Content.type, - created: "", + created: '', writable: true, - last_modified: "", + last_modified: '', mimetype: s3Content.mimetype, - content: s3Content.content, + content: s3Content.content } as Contents.IModel; return result; } @@ -125,37 +124,36 @@ export async function ls(path: string): Promise { const settings = ServerConnection.makeSettings(); // can be stored as class var const response = await ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", path), - { method: "GET", headers: { "X-Custom-S3-Is-Dir": "true" } }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', path), + { method: 'GET', headers: { 'X-Custom-S3-Is-Dir': 'true' } }, settings ) ).json(); const contents: Contents.IModel = { - type: "directory", + type: 'directory', path: path.trim(), - name: "", - format: "json", + name: '', + format: 'json', content: response.map((s3Content: any) => { return s3ToJupyterContents(s3Content); }), - created: "", + created: '', writable: true, - last_modified: "", - mimetype: "", + last_modified: '', + mimetype: '' }; return contents; } export async function read(path: string): Promise { - // pass const settings = ServerConnection.makeSettings(); // can be stored as class var const response = ( await ServerConnection.makeRequest( - URLExt.join(settings.baseUrl, "jupyterlab_s3_browser/files", path), - { method: "GET" }, + URLExt.join(settings.baseUrl, 'jupyterlab_s3_browser/contents', path), + { method: 'GET' }, settings ) ).json(); return response; // TODO: error handling -} +} \ No newline at end of file