diff --git a/backend/ianalyzer/urls.py b/backend/ianalyzer/urls.py index 9c6cf5471..bf2beaa7e 100644 --- a/backend/ianalyzer/urls.py +++ b/backend/ianalyzer/urls.py @@ -46,9 +46,10 @@ api_router.register('indexing/jobs', IndexJobViewset, basename='index-job') if settings.PROXY_FRONTEND: - spa_url = re_path(r'^(?P.*)$', proxy_frontend) + spa_url = re_path(r'^(?P(?!api\/)(?!users\/).*)$', proxy_frontend) else: - spa_url = re_path(r'', index) + spa_url = re_path(r'^((?!api\/)(?!users\/).*)', index) + urlpatterns = [ path('admin', RedirectView.as_view(url='/admin/', permanent=True)), diff --git a/frontend/src/app/services/api.service.spec.ts b/frontend/src/app/services/api.service.spec.ts index 8da20f562..827d9a652 100644 --- a/frontend/src/app/services/api.service.spec.ts +++ b/frontend/src/app/services/api.service.spec.ts @@ -1,7 +1,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { ApiService } from './api.service'; +import { ApiService, joinURLPath } from './api.service'; import { HttpClient, HttpErrorResponse, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { fakeNgramResult } from '@mock-data/api'; import { Subject, from, throwError } from 'rxjs'; @@ -73,3 +73,23 @@ describe('ApiService', () => { })); }); + +describe('joinURLPath', () => { + it('joins path segments', () => { + expect(joinURLPath('foo', 'bar', 'baz')).toBe('foo/bar/baz'); + }); + + it('removes double slashes', () => { + expect(joinURLPath('foo/', 'bar')).toBe('foo/bar'); + expect(joinURLPath('foo', '/bar')).toBe('foo/bar'); + expect(joinURLPath('foo/', '/bar')).toBe('foo/bar'); + }); + + it('does not remove leading slash', () => { + expect(joinURLPath('/foo', 'bar', 'baz')).toBe('/foo/bar/baz'); + }); + + it('does not remove trailing slash', () => { + expect(joinURLPath('foo', 'bar', 'baz/')).toBe('foo/bar/baz/'); + }); +}); diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index d3fc870de..89eaa39c5 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -34,9 +34,8 @@ import * as _ from 'lodash'; import { APIEditableCorpus, CorpusDataFile, - DataFileInfo, } from '@models/corpus-definition'; -import { APIIndexHealth, APIIndexJob, isComplete, JobStatus } from '@models/indexing'; +import { APIIndexHealth, APIIndexJob, isComplete } from '@models/indexing'; import { ImageInfo } from '@models/image'; interface SolisLoginResponse { @@ -48,13 +47,37 @@ interface SolisLoginResponse { queries: QueryDb[]; } +export const joinURLPath = (...parts: string[]) => { + const cleanParts = parts.map((part, i) => { + // remove trailing/leading slashes from segments + + // easy exception to provide '/' as an explicit segment; should never be trimmed. + if (part == '/') { + return part; + } + + const cleanStart: (s: string) => string = (i == 0) ? + _.identity : + s => _.trimStart(s, '/'); + + const cleanEnd: (s: string) => string = + (i == parts.length - 1) ? + _.identity : + s => _.trimEnd(s, '/'); + + return cleanStart(cleanEnd(part)); + }); + + return cleanParts.join('/'); +}; + @Injectable({ providedIn: 'root', }) export class ApiService { private apiUrl = environment.apiUrl; - private authApiUrl = 'users'; + private authApiUrl = '/users'; private visApiURL = 'visualization'; private downloadApiURL = 'download'; private corpusApiUrl = 'corpus'; @@ -62,28 +85,33 @@ export class ApiService { private indexApiUrl = 'indexing'; private authApiRoute = (route: string): string => - `/${this.authApiUrl}/${route}/`; + joinURLPath(this.authApiUrl, route); private apiRoute = (subApi: string, route: string): string => - `${this.apiUrl}/${subApi}/${route}`; + joinURLPath(this.apiUrl, subApi, route); constructor(private http: HttpClient) {} public deleteSearchHistory(): Observable { - return this.http.post('/api/search_history/delete_all/', {}); + return this.http.post( + joinURLPath(this.apiUrl, 'search_history/delete_all/'), {}); } public searchHistory() { - return this.http.get('/api/search_history/').toPromise(); + return this.http.get( + joinURLPath(this.apiUrl, 'search_history/') + ).toPromise(); } public downloads(): Promise { - return this.http.get('/api/download/').toPromise(); + return this.http.get( + joinURLPath(this.apiUrl, 'download/') + ).toPromise(); } // Media public getMedia(data: { args: string }): Promise { - const url = `/api/get_media${data.args}`; + const url = `${joinURLPath(this.apiUrl, 'get_media')}${data.args}`; return this.http .get(url, { observe: 'response', responseType: 'arraybuffer' }) .toPromise(); @@ -103,7 +131,7 @@ export class ApiService { }; return this.http .post<{ media: string[]; info?: ImageInfo }>( - '/api/request_media', + joinURLPath(this.apiUrl, 'request_media'), requestData ) .toPromise(); @@ -113,18 +141,23 @@ export class ApiService { corpus_index: string; filepath: string; }): Promise { - const url = `/api/download_pdf/${data.corpus_index}/${data.filepath}`; + const url = joinURLPath( + this.apiUrl, 'download_pdf', data.corpus_index, data.filepath + ); return this.http.get(url).toPromise(); } // Tasks public getTasksStatus(tasks: TaskResult): Observable { - return this.http.post('/api/task_status', tasks); + return this.http.post( + joinURLPath(this.apiUrl, 'task_status'), tasks + ); } public abortTasks(data: TaskResult): Promise { return this.http - .post('/api/abort_tasks', data) + .post( + joinURLPath(this.apiUrl, 'abort_tasks'), data) .toPromise(); } @@ -177,6 +210,7 @@ export class ApiService { data: DateTermFrequencyParameters ): Promise { const url = this.apiRoute(this.visApiURL, 'date_term_frequency'); + console.log(url); return this.http.post(url, data).toPromise(); } @@ -259,18 +293,20 @@ export class ApiService { /** fetch a list of all corpora available for searching */ public corpus() { - return this.http.get('/api/corpus/'); + return this.http.get(joinURLPath(this.apiUrl, 'corpus/')); } // Corpus definitions public corpusDefinitions(): Observable { - return this.http.get('/api/corpus/definitions/'); + return this.http.get( + joinURLPath(this.apiUrl, 'corpus/definitions/') + ); } public corpusDefinition(corpusID: number): Observable { return this.http.get( - `/api/corpus/definitions/${corpusID}/` + joinURLPath(this.apiUrl, `/corpus/definitions/${corpusID}/`) ); } @@ -278,7 +314,7 @@ export class ApiService { data: APIEditableCorpus ): Observable { return this.http.post( - '/api/corpus/definitions/', + joinURLPath(this.apiUrl, 'corpus/definitions/'), data ); } @@ -288,17 +324,20 @@ export class ApiService { data: APIEditableCorpus ): Observable { return this.http.put( - `/api/corpus/definitions/${corpusID}/`, + joinURLPath(this.apiUrl, `corpus/definitions/${corpusID}/`), data ); } public deleteCorpus(corpusID: number): Observable { - return this.http.delete(`/api/corpus/definitions/${corpusID}/`); + return this.http.delete( + joinURLPath(this.apiUrl, `corpus/definitions/${corpusID}/`)); } public corpusSchema(): Observable { - return this.http.get('/api/corpus/definition-schema'); + return this.http.get( + joinURLPath(this.apiUrl, 'corpus/definition-schema') + ); } public updateCorpusImage(corpusName: string, file: File): Observable { @@ -323,13 +362,13 @@ export class ApiService { formData.append('corpus', String(corpusId)); formData.append('is_sample', 'True'); return this.http.post( - `/api/corpus/datafiles/`, + this.apiRoute(this.corpusApiUrl, `datafiles/`), formData ); } public deleteDataFile(dataFile: CorpusDataFile): Observable { - const url = `/api/corpus/datafiles/${dataFile.id}/`; + const url = this.apiRoute(this.corpusApiUrl, `datafiles/${dataFile.id}/`); return this.http.delete(url); } @@ -337,7 +376,7 @@ export class ApiService { fileId: number, data: Partial ): Observable { - const url = `/api/corpus/datafiles/${fileId}/`; + const url = this.apiRoute(this.corpusApiUrl, `datafiles/${fileId}/`); return this.http.patch(url, data); } @@ -348,15 +387,17 @@ export class ApiService { const params = new HttpParams() .set('corpus', corpusId) .set('samples', samples); - return this.http.get('/api/corpus/datafiles/', { - params: params, - }); + return this.http.get( + this.apiRoute(this.corpusApiUrl, 'datafiles/'), + { params: params } + ); } // Tagging public userTags(): Observable { const url = this.apiRoute(this.tagApiUrl, 'tags/'); + console.log(url); return this.http.get(url); } @@ -398,7 +439,7 @@ export class ApiService { // Authentication API public login(username: string, password: string) { - return this.http.post<{ key: string }>(this.authApiRoute('login'), { + return this.http.post<{ key: string }>(this.authApiRoute('login/'), { username, password, }); @@ -406,13 +447,13 @@ export class ApiService { public logout() { return this.http.post<{ detail: string }>( - this.authApiRoute('logout'), + this.authApiRoute('logout/'), {} ); } public getUser() { - return this.http.get(this.authApiRoute('user')); + return this.http.get(this.authApiRoute('user/')); } public register(details: { @@ -421,26 +462,26 @@ export class ApiService { password1: string; password2: string; }) { - return this.http.post(this.authApiRoute('registration'), details); + return this.http.post(this.authApiRoute('registration/'), details); } public verify(key: string) { return this.http.post( - this.authApiRoute('registration/verify-email'), + this.authApiRoute('registration/verify-email/'), { key } ); } public keyInfo(key: string) { return this.http.post<{ username: string; email: string }>( - this.authApiRoute('registration/key-info'), + this.authApiRoute('registration/key-info/'), { key } ); } public requestResetPassword(email: string) { return this.http.post<{ detail: string }>( - this.authApiRoute('password/reset'), + this.authApiRoute('password/reset/'), { email } ); } @@ -452,7 +493,7 @@ export class ApiService { newPassword2: string ) { return this.http.post<{ detail: string }>( - this.authApiRoute('password/reset/confirm'), + this.authApiRoute('password/reset/confirm/'), { uid, token, @@ -482,13 +523,15 @@ export class ApiService { details: Partial ): Observable { return this.http.patch( - this.authApiRoute('user'), + this.authApiRoute('user/'), details ); } public solisLogin(data: any): Promise { - return this.http.get('/api/solislogin').toPromise(); + return this.http.get( + joinURLPath(this.apiUrl, 'solislogin'), + ).toPromise(); } // INDEXING diff --git a/frontend/src/app/services/elastic-search.service.ts b/frontend/src/app/services/elastic-search.service.ts index 144c4f70f..9d986440b 100644 --- a/frontend/src/app/services/elastic-search.service.ts +++ b/frontend/src/app/services/elastic-search.service.ts @@ -15,6 +15,8 @@ import { TagService } from './tag.service'; import { APIQuery } from '@models/search-requests'; import { PageResultsParameters } from '@models/page-results'; import { resultsParamsToAPIQuery } from '@utils/es-query'; +import { joinURLPath } from './api.service'; +import { environment } from '@environments/environment'; @Injectable() @@ -73,7 +75,7 @@ export class ElasticSearchService { * Execute an ElasticSearch query and return a dictionary containing the results. */ private async execute(corpus: Corpus, body: APIQuery) { - const url = `/api/es/${corpus.name}/_search`; + const url = joinURLPath(environment.apiUrl, 'es', corpus.name, '_search'); return this.http.post(url, body).toPromise(); } diff --git a/frontend/src/app/services/wordmodels.service.ts b/frontend/src/app/services/wordmodels.service.ts index eeae35614..a40c3e0f2 100644 --- a/frontend/src/app/services/wordmodels.service.ts +++ b/frontend/src/app/services/wordmodels.service.ts @@ -6,6 +6,8 @@ import { WordInModelResult, WordSimilarity, } from '@models'; +import { joinURLPath } from './api.service'; +import { environment } from '@environments/environment'; @Injectable() export class WordmodelsService { @@ -91,5 +93,6 @@ export class WordmodelsService { }); } - private wmApiRoute = (route: string): string => `/api/wordmodels/${route}`; + private wmApiRoute = (route: string): string => + joinURLPath(environment.apiUrl, 'wordmodels', route); }