From b481ad7add48048b839df8e4275a0568e25bb79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Agneray?= <francois.agneray@lam.fr> Date: Mon, 7 Mar 2022 17:07:58 +0100 Subject: [PATCH] WIP => Download result async --- .../result/datatable-actions.component.html | 12 +- .../result/datatable-actions.component.ts | 75 ++++++--- .../result/datatable-tab.component.html | 31 ++-- .../result/download-file-tab.component.html | 2 +- .../result/download-file-tab.component.ts | 8 + .../result/download.component.spec.ts | 4 +- .../components/result/download.component.ts | 8 +- server/app/dependencies.php | 20 ++- server/app/routes.php | 17 ++- server/src/Action/DownloadArchiveAction.php | 4 +- server/src/Action/DownloadResultAction.php | 111 ++++++++++++++ ...ction.php => IsArchiveAvailableAction.php} | 7 +- server/src/Action/IsResultAvailableAction.php | 84 ++++++++++ ...n.php => StartTaskCreateArchiveAction.php} | 2 +- .../Action/StartTaskCreateResultAction.php | 143 ++++++++++++++++++ tasks/src/anis_tasks/app.py | 6 +- tasks/src/anis_tasks/archive.py | 2 +- tasks/src/anis_tasks/result.py | 31 ++++ tasks/src/anis_tasks/utils.py | 2 +- 19 files changed, 505 insertions(+), 64 deletions(-) create mode 100644 server/src/Action/DownloadResultAction.php rename server/src/Action/{ArchiveIsAvailableAction.php => IsArchiveAvailableAction.php} (90%) create mode 100644 server/src/Action/IsResultAvailableAction.php rename server/src/Action/{ArchiveAction.php => StartTaskCreateArchiveAction.php} (98%) create mode 100644 server/src/Action/StartTaskCreateResultAction.php create mode 100644 tasks/src/anis_tasks/result.py diff --git a/client/src/app/instance/search/components/result/datatable-actions.component.html b/client/src/app/instance/search/components/result/datatable-actions.component.html index a1ff010a..f0f88863 100644 --- a/client/src/app/instance/search/components/result/datatable-actions.component.html +++ b/client/src/app/instance/search/components/result/datatable-actions.component.html @@ -18,15 +18,21 @@ <span class="fas fa-file"></span> VOtable </a> </li> - <li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem"> - <a class="dropdown-item" (click)="broadcastVotable()"> + <li *ngIf="getConfigDownloadResultFormat('download_vo')" role="menuitem" [class.disabled]="!sampRegistered"> + <a class="dropdown-item" [class.disabled]="!sampRegistered" (click)="broadcastVotable()"> <span class="fas fa-broadcast-tower"></span> Broadcast VOtable </a> </li> <li *ngIf="getConfigDownloadResultFormat('download_archive')" role="menuitem"> - <a class="dropdown-item" [href]="getUrlArchive()" (click)="click($event, getUrlArchive(), 'zip')"> + <a class="dropdown-item" [href]="createFilesArchiveUrl()" (click)="createFilesArchive($event, createFilesArchiveUrl())"> <span class="fas fa-archive"></span> Download files archive </a> </li> </ul> </div> + +<div *ngIf="archiveInProgress"> + <span class="fas fa-circle-notch fa-spin fa-3x"></span> + <span class="sr-only">Loading...</span> + Please wait archive is under construction... +</div> \ No newline at end of file diff --git a/client/src/app/instance/search/components/result/datatable-actions.component.ts b/client/src/app/instance/search/components/result/datatable-actions.component.ts index 5c294996..cdb42876 100644 --- a/client/src/app/instance/search/components/result/datatable-actions.component.ts +++ b/client/src/app/instance/search/components/result/datatable-actions.component.ts @@ -1,5 +1,6 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { interval, Subscription} from 'rxjs'; import { ToastrService } from 'ngx-toastr'; @@ -23,6 +24,12 @@ export class DatatableActionsComponent { @Input() dataLength: number; @Input() sampRegistered: boolean; @Output() broadcast: EventEmitter<string> = new EventEmitter(); + @Output() startsDownloadingFile: EventEmitter<{url: string, filename: string}> = new EventEmitter(); + + archiveName = ''; + archiveId =''; + archiveInProgress = false; + archiveIsAvailableSubscription: Subscription constructor(private appConfig: AppConfigService, private http: HttpClient, private toastr: ToastrService) { } @@ -68,15 +75,27 @@ export class DatatableActionsComponent { return `${attributeId.id}::in::${this.selectedData.join('|')}`; } + /** + * Allows to download file. + */ + click(event, href, extension): void { + event.preventDefault(); + + const url = href; + const filename = `${this.datasetSelected}.${extension}`; + + this.startsDownloadingFile.emit({ url, filename }); + } + /** * Returns URL to download archive. * * @return boolean */ - getUrlArchive(): string { - let query: string = `${getHost(this.appConfig.apiUrl)}/archive/${this.datasetSelected}?a=${this.outputList.join(';')}`; + createFilesArchiveUrl(): string { + let query: string = `${getHost(this.appConfig.apiUrl)}/start-task-create-archive/${this.datasetSelected}?a=${this.outputList.join(';')}`; if (this.criteriaList.length > 0) { - query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')};${this.getCriterionSelectedData()}`; + query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')}`; } else { query += `&c=${this.getCriterionSelectedData()}`; } @@ -86,6 +105,36 @@ export class DatatableActionsComponent { return query; } + getArchiveUrl() { + return `${getHost(this.appConfig.apiUrl)}/download-archive/${this.datasetSelected}/${this.archiveId}`; + } + + testArchiveIsAvailable() { + const url = `${getHost(this.appConfig.apiUrl)}/is-archive-available/${this.archiveId}`; + this.http.get<{"archive_is_available": boolean}>(url).subscribe(data => { + console.log(data); + if (data.archive_is_available) { + this.archiveInProgress = false; + this.archiveIsAvailableSubscription.unsubscribe(); + this.startsDownloadingFile.emit({ url: this.getArchiveUrl(), filename: this.archiveName }) + } + }); + } + + /** + * Starts the creation of the files archive + */ + createFilesArchive(event, href): void { + event.preventDefault(); + + this.http.get<{"archive_name": string, "archive_id": string}>(href).subscribe(data => { + this.archiveInProgress = true; + this.archiveName = data.archive_name; + this.archiveId = data.archive_id; + this.archiveIsAvailableSubscription = interval(1000).subscribe(() => this.testArchiveIsAvailable()); + }); + } + /** * Emits event to action to broadcast data. * @@ -95,23 +144,7 @@ export class DatatableActionsComponent { this.broadcast.emit(this.getUrl('votable')); } - /** - * Allows to download file. - */ - click(event, href, extension): void { - event.preventDefault(); - - if (extension === 'zip') { - this.toastr.info('Achive is under construction, please wait', 'Download archive'); - } - - this.http.get(href, {responseType: "blob"}).subscribe( - data => { - let downloadLink = document.createElement('a'); - downloadLink.href = window.URL.createObjectURL(data); - downloadLink.setAttribute('download', `${this.datasetSelected}.${extension}`); - downloadLink.click(); - } - ); + ngOnDestroy() { + this.archiveIsAvailableSubscription.unsubscribe(); } } diff --git a/client/src/app/instance/search/components/result/datatable-tab.component.html b/client/src/app/instance/search/components/result/datatable-tab.component.html index c638c569..e4b9620c 100644 --- a/client/src/app/instance/search/components/result/datatable-tab.component.html +++ b/client/src/app/instance/search/components/result/datatable-tab.component.html @@ -18,23 +18,24 @@ [coneSearch]="coneSearch" [dataLength]="dataLength" [sampRegistered]="sampRegistered" - (broadcast)="broadcast.emit($event)"> + (broadcast)="broadcast.emit($event)" + (startsDownloadingFile)="startsDownloadingFile.emit($event)"> </app-datatable-actions> <app-datatable - [dataset]="datasetList | datasetByName:datasetSelected" - [instance]="instance" - [attributeList]="attributeList" - [outputList]="outputList" - [queryParams]="queryParams" - [dataLength]="dataLength" - [data]="data" - [dataIsLoading]="dataIsLoading" - [dataIsLoaded]="dataIsLoaded" - [selectedData]="selectedData" - (retrieveData)="retrieveData.emit($event)" - (addSelectedData)="addSelectedData.emit($event)" - (deleteSelectedData)="deleteSelectedData.emit($event)" - (startsDownloadingFile)="startsDownloadingFile.emit($event)"> + [dataset]="datasetList | datasetByName:datasetSelected" + [instance]="instance" + [attributeList]="attributeList" + [outputList]="outputList" + [queryParams]="queryParams" + [dataLength]="dataLength" + [data]="data" + [dataIsLoading]="dataIsLoading" + [dataIsLoaded]="dataIsLoaded" + [selectedData]="selectedData" + (retrieveData)="retrieveData.emit($event)" + (addSelectedData)="addSelectedData.emit($event)" + (deleteSelectedData)="deleteSelectedData.emit($event)" + (startsDownloadingFile)="startsDownloadingFile.emit($event)"> </app-datatable> </accordion-group> </accordion> diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.html b/client/src/app/instance/search/components/result/download-file-tab.component.html index 2030ad73..b47e6be3 100644 --- a/client/src/app/instance/search/components/result/download-file-tab.component.html +++ b/client/src/app/instance/search/components/result/download-file-tab.component.html @@ -4,7 +4,7 @@ <ul> <li *ngFor="let downloadFile of downloadedFiles"> {{ downloadFile.name }} : - <progressbar [value]="downloadFile.progress" type="warning" [striped]="false">{{ downloadFile.progress }}%</progressbar> + <progressbar [value]="downloadFile.progress" [type]="getType(downloadFile.progress)" [animate]="true">{{ downloadFile.progress }}%</progressbar> </li> </ul> </div> diff --git a/client/src/app/instance/search/components/result/download-file-tab.component.ts b/client/src/app/instance/search/components/result/download-file-tab.component.ts index cc8ad76c..95d1e5b6 100644 --- a/client/src/app/instance/search/components/result/download-file-tab.component.ts +++ b/client/src/app/instance/search/components/result/download-file-tab.component.ts @@ -22,4 +22,12 @@ import { DownloadFile } from 'src/app/instance/store/models'; }) export class DownloadFileTabComponent { @Input() downloadedFiles: DownloadFile[]; + + getType(value: number): 'success' | 'info' { + if (value < 100) { + return 'info'; + } + + return 'success'; + } } diff --git a/client/src/app/instance/search/components/result/download.component.spec.ts b/client/src/app/instance/search/components/result/download.component.spec.ts index 840d91af..d2129d21 100644 --- a/client/src/app/instance/search/components/result/download.component.spec.ts +++ b/client/src/app/instance/search/components/result/download.component.spec.ts @@ -395,7 +395,7 @@ describe('[Instance][Search][Component][Result] DownloadComponent', () => { expect(component.getUrl('csv')).toBe('http://test.com/search/myDataset?a=1;2;3&c=1::eq::one;2::eq::two&cs=4:5:6&f=csv'); }); - it('#getUrlArchive() should construct url to access to archive', () => { + it('#getArchiveUrl() should construct url to access to archive', () => { appConfigServiceStub.apiUrl = 'http://test.com'; component.datasetSelected = 'myDataset'; component.outputList = [1, 2, 3]; @@ -404,7 +404,7 @@ describe('[Instance][Search][Component][Result] DownloadComponent', () => { {'id':2,'type':'field','operator':'eq','value':'two'} as FieldCriterion ]; component.coneSearch = { ra: 4, dec: 5, radius: 6 }; - expect(component.getUrlArchive()).toBe('http://test.com/archive/myDataset?a=1;2;3&c=1::eq::one;2::eq::two&cs=4:5:6'); + expect(component.getArchiveUrl()).toBe('http://test.com/archive/myDataset?a=1;2;3&c=1::eq::one;2::eq::two&cs=4:5:6'); }); it('#broadcastVotable() should raise broadcast event when clicked', () => { diff --git a/client/src/app/instance/search/components/result/download.component.ts b/client/src/app/instance/search/components/result/download.component.ts index 9a8f21ad..95bd94a0 100644 --- a/client/src/app/instance/search/components/result/download.component.ts +++ b/client/src/app/instance/search/components/result/download.component.ts @@ -102,7 +102,7 @@ export class DownloadComponent implements OnDestroy { const url = href; const filename = `${this.datasetSelected}.${extension}`; - this.startsDownloadingFile.emit({ url, filename }) + this.startsDownloadingFile.emit({ url, filename }); } /** @@ -120,7 +120,7 @@ export class DownloadComponent implements OnDestroy { * @return boolean */ createFilesArchiveUrl(): string { - let query: string = `${getHost(this.appConfig.apiUrl)}/archive/${this.datasetSelected}?a=${this.outputList.join(';')}`; + let query: string = `${getHost(this.appConfig.apiUrl)}/start-task-create-archive/${this.datasetSelected}?a=${this.outputList.join(';')}`; if (this.criteriaList.length > 0) { query += `&c=${this.criteriaList.map(criterion => criterionToString(criterion)).join(';')}`; } @@ -131,11 +131,11 @@ export class DownloadComponent implements OnDestroy { } getArchiveUrl() { - return `${getHost(this.appConfig.apiUrl)}/download-archive/${this.datasetSelected}?archive_id=${this.archiveId}`; + return `${getHost(this.appConfig.apiUrl)}/download-archive/${this.datasetSelected}/${this.archiveId}`; } testArchiveIsAvailable() { - const url = `${getHost(this.appConfig.apiUrl)}/archive-is-available?archive_id=${this.archiveId}`; + const url = `${getHost(this.appConfig.apiUrl)}/is-archive-available/${this.archiveId}`; this.http.get<{"archive_is_available": boolean}>(url).subscribe(data => { if (data.archive_is_available) { this.archiveInProgress = false; diff --git a/server/app/dependencies.php b/server/app/dependencies.php index d96c1080..c8d86647 100644 --- a/server/app/dependencies.php +++ b/server/app/dependencies.php @@ -211,12 +211,24 @@ $container->set('App\Action\SearchAction', function (ContainerInterface $c) { ); }); -$container->set('App\Action\ArchiveAction', function (ContainerInterface $c) { - return new App\Action\ArchiveAction($c->get('em'), $c->get('rmq'), $c->get(SETTINGS)['token']); +$container->set('App\Action\StartTaskCreateResultAction', function (ContainerInterface $c) { + return new App\Action\StartTaskCreateResultAction($c->get('em'), $c->get('rmq'), $c->get(SETTINGS)['token']); }); -$container->set('App\Action\ArchiveIsAvailableAction', function (ContainerInterface $c) { - return new App\Action\ArchiveIsAvailableAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); +$container->set('App\Action\IsResultAvailableAction', function (ContainerInterface $c) { + return new App\Action\IsResultAvailableAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); +}); + +$container->set('App\Action\DownloadResultAction', function (ContainerInterface $c) { + return new App\Action\DownloadResultAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); +}); + +$container->set('App\Action\StartTaskCreateArchiveAction', function (ContainerInterface $c) { + return new App\Action\StartTaskCreateArchiveAction($c->get('em'), $c->get('rmq'), $c->get(SETTINGS)['token']); +}); + +$container->set('App\Action\IsArchiveAvailableAction', function (ContainerInterface $c) { + return new App\Action\IsArchiveAvailableAction($c->get('em'), $c->get('settings')['data_path'], $c->get(SETTINGS)['token']); }); $container->set('App\Action\DownloadArchiveAction', function (ContainerInterface $c) { diff --git a/server/app/routes.php b/server/app/routes.php index 634a3599..a8aa5536 100644 --- a/server/app/routes.php +++ b/server/app/routes.php @@ -15,6 +15,7 @@ use Slim\Routing\RouteCollectorProxy; $app->get('/', App\Action\RootAction::class); $app->get('/client-settings', App\Action\ClientSettingsAction::class); +// Metamodel actions $app->group('', function (RouteCollectorProxy $group) { $group->map([OPTIONS, GET, POST], '/select', App\Action\SelectListAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/select/{name}', App\Action\SelectAction::class); @@ -30,6 +31,7 @@ $app->group('', function (RouteCollectorProxy $group) { explode(',', $container->get(SETTINGS)['token']['admin_roles']) )); +// Metamodel actions $app->group('', function (RouteCollectorProxy $group) { $group->map([OPTIONS, GET, POST], '/survey', App\Action\SurveyListAction::class); $group->map([OPTIONS, GET, PUT, DELETE], '/survey/{name}', App\Action\SurveyAction::class); @@ -67,10 +69,19 @@ $app->group('', function (RouteCollectorProxy $group) { explode(',', $container->get(SETTINGS)['token']['admin_roles']) )); +// Search actions $app->get('/search/{dname}', App\Action\SearchAction::class); -$app->get('/archive/{dname}', App\Action\ArchiveAction::class); -$app->get('/archive-is-available', App\Action\ArchiveIsAvailableAction::class); -$app->get('/download-archive/{dname}', App\Action\DownloadArchiveAction::class); + +$app->get('/start-task-create-result/{dname}', App\Action\StartTaskCreateResultAction::class); +$app->get('/is-result-available/{id}', App\Action\IsResultAvailableAction::class); +$app->get('/download-result/{dname}/{id}', App\Action\DownloadResultAction::class); + +// Archive actions +$app->get('/start-task-create-archive/{dname}', App\Action\StartTaskCreateArchiveAction::class); +$app->get('/is-archive-available/{id}', App\Action\IsArchiveAvailableAction::class); +$app->get('/download-archive/{dname}/{id}', App\Action\DownloadArchiveAction::class); + +// Explore and download individual files $app->get('/dataset-file-explorer/{dname}[{fpath:.*}]', App\Action\DatasetFileExplorerAction::class); $app->get('/download-instance-file/{iname}/[{fpath:.*}]', App\Action\DownloadInstanceFileAction::class); $app->get('/download-file/{dname}/[{fpath:.*}]', App\Action\DownloadFileAction::class); diff --git a/server/src/Action/DownloadArchiveAction.php b/server/src/Action/DownloadArchiveAction.php index 13ce380b..f01c8769 100644 --- a/server/src/Action/DownloadArchiveAction.php +++ b/server/src/Action/DownloadArchiveAction.php @@ -87,10 +87,8 @@ final class DownloadArchiveAction extends AbstractAction ); } - $queryParams = $request->getQueryParams(); - $archiveId = $queryParams['archive_id']; - // Search the file + $archiveId = $args['id']; $filePath = $this->dataPath . '/ARCHIVE/' . $archiveId . '.zip'; // If the file not found 404 diff --git a/server/src/Action/DownloadResultAction.php b/server/src/Action/DownloadResultAction.php new file mode 100644 index 00000000..29860871 --- /dev/null +++ b/server/src/Action/DownloadResultAction.php @@ -0,0 +1,111 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Doctrine\ORM\EntityManagerInterface; +use Slim\Exception\HttpNotFoundException; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class DownloadResultAction extends AbstractAction +{ + /** + * Contains anis-server data path + * + * @var string + */ + private $dataPath; + + /** + * Contains settings to handle Json Web Token + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param string $dataPath Contains anis-server data path + * @param array $settings Settings about token + */ + public function __construct(EntityManagerInterface $em, string $dataPath, array $settings) + { + parent::__construct($em); + $this->dataPath = $dataPath; + $this->settings = $settings; + } + + /** + * `GET` Returns the file found + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + // Search the correct dataset with primary key + $dataset = $this->em->find('App\Entity\Dataset', $args['dname']); + + // If dataset is not found 404 + if (is_null($dataset)) { + throw new HttpNotFoundException( + $request, + 'Dataset with name ' . $args['dname'] . ' is not found' + ); + } + + // If dataset is private and authorization enabled + if (!$dataset->getPublic() && boolval($this->settings['enabled'])) { + $this->verifyDatasetAuthorization( + $request, + $dataset->getName(), + explode(',', $this->settings['admin_roles']) + ); + } + + // Search the file + $fileId = $args['id']; + $filePath = $this->dataPath . '/RESULT/' . $fileId; + + // If the file not found 404 + if (!file_exists($filePath)) { + throw new HttpNotFoundException( + $request, + 'Result file with name ' . $fileId . ' is not found' + ); + } + + // If the file found so stream it + $psr17Factory = new Psr17Factory(); + $stream = $psr17Factory->createStreamFromFile($filePath, 'r'); + + return $response->withBody($stream) + ->withHeader('Content-Disposition', 'attachment; filename=' . basename($filePath) . ';') + ->withHeader('Content-Type', mime_content_type($filePath)) + ->withHeader('Content-Length', filesize($filePath)); + } +} diff --git a/server/src/Action/ArchiveIsAvailableAction.php b/server/src/Action/IsArchiveAvailableAction.php similarity index 90% rename from server/src/Action/ArchiveIsAvailableAction.php rename to server/src/Action/IsArchiveAvailableAction.php index 81219d4b..3548088c 100644 --- a/server/src/Action/ArchiveIsAvailableAction.php +++ b/server/src/Action/IsArchiveAvailableAction.php @@ -22,7 +22,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; * @author François Agneray <francois.agneray@lam.fr> * @package App\Action */ -final class ArchiveIsAvailableAction extends AbstractAction +final class IsArchiveAvailableAction extends AbstractAction { /** * Contains anis-server data path @@ -67,11 +67,10 @@ final class ArchiveIsAvailableAction extends AbstractAction return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); } - $queryParams = $request->getQueryParams(); - $archiveId = $queryParams['archive_id'] . '.zip'; + $archiveId = $args['id']; // Search the file - $filePath = $this->dataPath . '/ARCHIVE/' . $archiveId; + $filePath = $this->dataPath . '/ARCHIVE/' . $archiveId . '.zip'; $isAvailable = false; if (file_exists($filePath)) { diff --git a/server/src/Action/IsResultAvailableAction.php b/server/src/Action/IsResultAvailableAction.php new file mode 100644 index 00000000..fa612318 --- /dev/null +++ b/server/src/Action/IsResultAvailableAction.php @@ -0,0 +1,84 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Doctrine\ORM\EntityManagerInterface; +use Slim\Exception\HttpNotFoundException; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class IsResultAvailableAction extends AbstractAction +{ + /** + * Contains anis-server data path + * + * @var string + */ + private $dataPath; + + /** + * Contains settings to handle Json Web Token + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param string $dataPath Contains anis-server data path + * @param array $settings Settings about token + */ + public function __construct(EntityManagerInterface $em, string $dataPath, array $settings) + { + parent::__construct($em); + $this->dataPath = $dataPath; + $this->settings = $settings; + } + + /** + * `GET` Returns the file found + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + $fileId = $args['id']; + + // Search the file + $filePath = $this->dataPath . '/RESULT/' . $fileId; + + $isAvailable = false; + if (file_exists($filePath)) { + $isAvailable = true; + } + + $payload = json_encode(array('file_is_available' => $isAvailable)); + $response->getBody()->write($payload); + return $response; + } +} diff --git a/server/src/Action/ArchiveAction.php b/server/src/Action/StartTaskCreateArchiveAction.php similarity index 98% rename from server/src/Action/ArchiveAction.php rename to server/src/Action/StartTaskCreateArchiveAction.php index a6009f5a..898183d4 100644 --- a/server/src/Action/ArchiveAction.php +++ b/server/src/Action/StartTaskCreateArchiveAction.php @@ -31,7 +31,7 @@ use PhpAmqpLib\Message\AMQPMessage; * @author François Agneray <francois.agneray@lam.fr> * @package App\Action */ -final class ArchiveAction extends AbstractAction +final class StartTaskCreateArchiveAction extends AbstractAction { /** * Contains RabbitMQ connection socket diff --git a/server/src/Action/StartTaskCreateResultAction.php b/server/src/Action/StartTaskCreateResultAction.php new file mode 100644 index 00000000..567ed6a1 --- /dev/null +++ b/server/src/Action/StartTaskCreateResultAction.php @@ -0,0 +1,143 @@ +<?php + +/* + * This file is part of Anis Server. + * + * (c) Laboratoire d'Astrophysique de Marseille / CNRS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +declare(strict_types=1); + +namespace App\Action; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Slim\Exception\HttpBadRequestException; +use Slim\Exception\HttpNotFoundException; +use Slim\Exception\HttpInternalServerErrorException; +use Doctrine\ORM\EntityManagerInterface; +use Nyholm\Psr7\Factory\Psr17Factory; +use App\Search\DBALConnectionFactory; +use App\Search\Query\AnisQueryBuilder; +use App\Search\Response\IResponseFactory; +use App\Search\SearchException; +use PhpAmqpLib\Connection\AbstractConnection; +use PhpAmqpLib\Connection\AMQPStreamConnection; +use PhpAmqpLib\Message\AMQPMessage; + +/** + * @author François Agneray <francois.agneray@lam.fr> + * @package App\Action + */ +final class StartTaskCreateArchiveAction extends AbstractAction +{ + /** + * Contains RabbitMQ connection socket + * + * @var AbstractConnection + */ + private $rmq; + + /** + * Contains settings to handle Json Web Token (app/settings.php) + * + * @var array + */ + private $settings; + + /** + * Create the classe before call __invoke to execute the action + * + * @param EntityManagerInterface $em Doctrine Entity Manager Interface + * @param AbstractConnection $rmq RabbitMQ connection socket + * @param array $settings Settings about token + */ + public function __construct( + EntityManagerInterface $em, + AbstractConnection $rmq, + array $settings + ) { + parent::__construct($em); + $this->rmq = $rmq; + $this->settings = $settings; + } + + /** + * `GET` Starts an asynchronous task, through rabbitmq, to build an archive + * + * @param ServerRequestInterface $request PSR-7 This object represents the HTTP request + * @param ResponseInterface $response PSR-7 This object represents the HTTP response + * @param string[] $args This table contains information transmitted in the URL (see routes.php) + * + * @return ResponseInterface + */ + public function __invoke(Request $request, Response $response, array $args): Response + { + if ($request->getMethod() === OPTIONS) { + return $response->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + // Search the correct dataset with primary key + $datasetName = $args['dname']; + $dataset = $this->em->find('App\Entity\Dataset', $datasetName); + + // If dataset is not found 404 + if (is_null($dataset)) { + throw new HttpNotFoundException( + $request, + 'Dataset with name ' . $datasetName . ' is not found' + ); + } + + // If dataset is private and authorization enabled + if (!$dataset->getPublic() && boolval($this->settings['enabled'])) { + $this->verifyDatasetAuthorization( + $request, + $dataset->getName(), + explode(',', $this->settings['admin_roles']) + ); + } + + $queryParams = $request->getQueryParams(); + + // The parameter "a" is mandatory + if (!array_key_exists('a', $queryParams)) { + throw new HttpBadRequestException( + $request, + 'Param a is required for this request' + ); + } + + // Search extension + if ($queryParams === 'csv') { + $extension = '.csv'; + } else if ($queryParams === 'ascii') { + $extension = '.txt'; + } else if ($queryParams === 'votable') { + $extension = '.xml'; + } else { + $extension = '.json'; + } + + // Create the name of the future archive + $fileName = 'result_' . $dataset->getName() . '_' . (new \DateTime())->format('Y-m-d\TH:i:s') . '.' . $extension; + $fileId = uniqid(); + + // Publish message in the archive queue + $channel = $this->rmq->channel(); + $channel->queue_declare('archive', false, false, false, false); + $msg = new AMQPMessage(json_encode(array( + 'file_id' => $fileId, + 'dataset_name' => $datasetName, + 'query' => $request->getUri()->getQuery() + ))); + $channel->basic_publish($msg, '', 'result'); + + // Just returns the future archive name + $payload = json_encode(array('file_name' => $fileName, 'file_id' => $fileId)); + $response->getBody()->write($payload); + return $response; + } +} diff --git a/tasks/src/anis_tasks/app.py b/tasks/src/anis_tasks/app.py index 810e581c..5dcd1b27 100644 --- a/tasks/src/anis_tasks/app.py +++ b/tasks/src/anis_tasks/app.py @@ -6,7 +6,7 @@ import pika from retry import retry # Local application imports -from anis_tasks import utils, archive +from anis_tasks import utils, archive, result @retry(pika.exceptions.AMQPConnectionError, delay=5, jitter=(1, 3)) def run(): @@ -23,6 +23,10 @@ def run(): channel.queue_declare(queue='archive') channel.basic_consume(queue='archive', on_message_callback=archive.archive_handler, auto_ack=True) + # Add result task handler + channel.queue_declare(queue='result') + channel.basic_consume(queue='result', on_message_callback=result.result_handler, auto_ack=True) + # Start logging.info("ANIS tasks started") channel.start_consuming() diff --git a/tasks/src/anis_tasks/archive.py b/tasks/src/anis_tasks/archive.py index 1f799b77..d439f79e 100644 --- a/tasks/src/anis_tasks/archive.py +++ b/tasks/src/anis_tasks/archive.py @@ -25,7 +25,7 @@ def archive_handler(ch, method, properties, body): zip = ZipFile(zip_path + ".tmp", 'w') # Search files - for row in data: + for row in data.json(): files_added = [] for attribute in attributes_selected: attribute_label = attribute["label"] diff --git a/tasks/src/anis_tasks/result.py b/tasks/src/anis_tasks/result.py new file mode 100644 index 00000000..1278e00d --- /dev/null +++ b/tasks/src/anis_tasks/result.py @@ -0,0 +1,31 @@ +# Standard library imports +import logging, json, os +from zipfile import ZipFile + +# Local application imports +from anis_tasks import utils + +def result_handler(ch, method, properties, body): + logging.info("Processing a new result message") + + # Decode JSON + message = json.loads(body) + + # Retrieve data + data = utils.search_data(message["dataset_name"], message["query"]) + + # create a File object + data_path = utils.get_data_path() + file_path = data_path + "/RESULT/" + message["file_id"] + file = open(file_path + ".tmp", "w") + + # Write data + file.write(data) + + # close the File + file.close() + + # Rename the tmp file with the correct filename (id) + os.rename(file_path + ".tmp", file_path) + + logging.info("File created: " + file_path) diff --git a/tasks/src/anis_tasks/utils.py b/tasks/src/anis_tasks/utils.py index 738e6cce..689f4517 100644 --- a/tasks/src/anis_tasks/utils.py +++ b/tasks/src/anis_tasks/utils.py @@ -90,7 +90,7 @@ def search_data(dname, query): if (r.status_code == 500): raise AnisServerError(r.json()["message"]) - return r.json() + return r class ConfigKeyNotFound(Exception): """ -- GitLab