diff --git a/api-client/post-process.py b/api-client/post-process.py index 7a7beae..e9f2c4f 100755 --- a/api-client/post-process.py +++ b/api-client/post-process.py @@ -16,16 +16,22 @@ def file_modifications() -> Generator[tuple[Path, FileModifier], None, None]: + """Return a generator of file paths and their corresponding modification functions.""" + yield Path("types/ObjectParamAPI.ts"), object_param_api_ts yield Path("types/PromiseAPI.ts"), promise_api_ts + yield Path("models/ObjectSerializer.ts"), object_serializer_ts + yield Path("models/Results.ts"), results_ts def main(): + """Main function to perform file modifications.""" + logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") subdir = Path("typescript") for file_path, modify_fn in file_modifications(): - logging.info(f"Modifying {file_path}…") + logging.info("Modifying %s…", file_path) file_path = subdir / file_path @@ -36,8 +42,8 @@ def main(): with open(file_path, "w", encoding="utf-8") as f: for line in modify_fn(file_contents): f.write(line) - except Exception as e: - logging.error(f"Error modifying {file_path}: {e}") + except Exception as e: # pylint: disable=broad-exception-caught + logging.error("Error modifying %s: %s", file_path, e) def object_param_api_ts(file_contents: list[str]) -> Generator[str, None, None]: @@ -56,5 +62,21 @@ def promise_api_ts(file_contents: list[str]) -> Generator[str, None, None]: yield line +def results_ts(file_contents: list[str]) -> Generator[str, None, None]: + """Modify the Results.ts file.""" + for line in file_contents: + if dedent(line).startswith("import { HttpFile } from '../http/http';"): + line = line + "import { InlineOrRefData } from './InlineOrRefData';\n" + yield line + + +def object_serializer_ts(file_contents: list[str]) -> Generator[str, None, None]: + """Modify the ObjectSerializer.ts file.""" + for line in file_contents: + if dedent(line).startswith('"Results": ResultsClass,'): + line = "" # lead to call of missing `getAttributeTypeMap` method + yield line + + if __name__ == "__main__": main() diff --git a/api-client/typescript/.openapi-generator/FILES b/api-client/typescript/.openapi-generator/FILES index 3665e68..a3f84d7 100644 --- a/api-client/typescript/.openapi-generator/FILES +++ b/api-client/typescript/.openapi-generator/FILES @@ -40,6 +40,7 @@ models/MaxOccurs.ts models/Metadata.ts models/NDVIProcessInputs.ts models/NDVIProcessOutputs.ts +models/NDVIProcessParams.ts models/ObjectSerializer.ts models/Output.ts models/OutputDescription.ts @@ -52,6 +53,7 @@ models/ProcessList.ts models/ProcessSummary.ts models/QualifiedInputValue.ts models/Response.ts +models/Results.ts models/Schema.ts models/StatusCode.ts models/StatusInfo.ts diff --git a/api-client/typescript/apis/ProcessesApi.ts b/api-client/typescript/apis/ProcessesApi.ts index 1ba501a..367cee0 100644 --- a/api-client/typescript/apis/ProcessesApi.ts +++ b/api-client/typescript/apis/ProcessesApi.ts @@ -10,12 +10,12 @@ import {SecurityAuthentication} from '../auth/auth'; import { Exception } from '../models/Exception'; import { Execute } from '../models/Execute'; -import { InlineOrRefData } from '../models/InlineOrRefData'; import { JobList } from '../models/JobList'; -import { NDVIProcessInputs } from '../models/NDVIProcessInputs'; import { NDVIProcessOutputs } from '../models/NDVIProcessOutputs'; +import { NDVIProcessParams } from '../models/NDVIProcessParams'; import { Process } from '../models/Process'; import { ProcessList } from '../models/ProcessList'; +import { Results } from '../models/Results'; import { StatusInfo } from '../models/StatusInfo'; /** @@ -56,14 +56,14 @@ export class ProcessesApiRequestFactory extends BaseAPIRequestFactory { } /** - * @param nDVIProcessInputs + * @param nDVIProcessParams */ - public async executeNdvi(nDVIProcessInputs: NDVIProcessInputs, _options?: Configuration): Promise { + public async executeNdvi(nDVIProcessParams: NDVIProcessParams, _options?: Configuration): Promise { let _config = _options || this.configuration; - // verify required parameter 'nDVIProcessInputs' is not null or undefined - if (nDVIProcessInputs === null || nDVIProcessInputs === undefined) { - throw new RequiredError("ProcessesApi", "executeNdvi", "nDVIProcessInputs"); + // verify required parameter 'nDVIProcessParams' is not null or undefined + if (nDVIProcessParams === null || nDVIProcessParams === undefined) { + throw new RequiredError("ProcessesApi", "executeNdvi", "nDVIProcessParams"); } @@ -81,7 +81,7 @@ export class ProcessesApiRequestFactory extends BaseAPIRequestFactory { ]); requestContext.setHeaderParam("Content-Type", contentType); const serializedBody = ObjectSerializer.stringify( - ObjectSerializer.serialize(nDVIProcessInputs, "NDVIProcessInputs", ""), + ObjectSerializer.serialize(nDVIProcessParams, "NDVIProcessParams", ""), contentType ); requestContext.setBody(serializedBody); @@ -148,10 +148,14 @@ export class ProcessesApiRequestFactory extends BaseAPIRequestFactory { /** * For more information, see [Section 11](https://docs.ogc.org/is/18-062/18-062.html#sc_job_list). * Retrieve the list of jobs + * @param limit Amount of items to return + * @param offset Offset into the items list */ - public async jobs(_options?: Configuration): Promise { + public async jobs(limit?: number, offset?: number, _options?: Configuration): Promise { let _config = _options || this.configuration; + + // Path Params const localVarPath = '/jobs'; @@ -159,6 +163,16 @@ export class ProcessesApiRequestFactory extends BaseAPIRequestFactory { const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") + // Query Params + if (limit !== undefined) { + requestContext.setQueryParam("limit", ObjectSerializer.serialize(limit, "number", "")); + } + + // Query Params + if (offset !== undefined) { + requestContext.setQueryParam("offset", ObjectSerializer.serialize(offset, "number", "")); + } + const defaultAuth: SecurityAuthentication | undefined = _config?.authMethods?.default @@ -365,13 +379,13 @@ export class ProcessesApiResponseProcessor { * @params response Response returned by the server for a request to execution * @throws ApiException if the response code was not in [200, 299] */ - public async executionWithHttpInfo(response: ResponseContext): Promise> { + public async executionWithHttpInfo(response: ResponseContext): Promise> { const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); if (isCodeInRange("200", response.httpStatusCode)) { - const body: { [key: string]: InlineOrRefData; } = ObjectSerializer.deserialize( + const body: Results = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "{ [key: string]: InlineOrRefData; }", "" - ) as { [key: string]: InlineOrRefData; }; + "Results", "" + ) as Results; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } if (isCodeInRange("404", response.httpStatusCode)) { @@ -384,10 +398,10 @@ export class ProcessesApiResponseProcessor { // Work around for missing responses in specification, e.g. for petstore.yaml if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { - const body: { [key: string]: InlineOrRefData; } = ObjectSerializer.deserialize( + const body: Results = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "{ [key: string]: InlineOrRefData; }", "" - ) as { [key: string]: InlineOrRefData; }; + "Results", "" + ) as Results; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } @@ -509,13 +523,13 @@ export class ProcessesApiResponseProcessor { * @params response Response returned by the server for a request to results * @throws ApiException if the response code was not in [200, 299] */ - public async resultsWithHttpInfo(response: ResponseContext): Promise> { + public async resultsWithHttpInfo(response: ResponseContext): Promise> { const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); if (isCodeInRange("200", response.httpStatusCode)) { - const body: { [key: string]: InlineOrRefData; } = ObjectSerializer.deserialize( + const body: Results = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "{ [key: string]: InlineOrRefData; }", "" - ) as { [key: string]: InlineOrRefData; }; + "Results", "" + ) as Results; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } if (isCodeInRange("404", response.httpStatusCode)) { @@ -528,10 +542,10 @@ export class ProcessesApiResponseProcessor { // Work around for missing responses in specification, e.g. for petstore.yaml if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { - const body: { [key: string]: InlineOrRefData; } = ObjectSerializer.deserialize( + const body: Results = ObjectSerializer.deserialize( ObjectSerializer.parse(await response.body.text(), contentType), - "{ [key: string]: InlineOrRefData; }", "" - ) as { [key: string]: InlineOrRefData; }; + "Results", "" + ) as Results; return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); } diff --git a/api-client/typescript/apis/UserApi.ts b/api-client/typescript/apis/UserApi.ts index 2a02d50..dc0a56b 100644 --- a/api-client/typescript/apis/UserApi.ts +++ b/api-client/typescript/apis/UserApi.ts @@ -18,11 +18,18 @@ import { UserSession } from '../models/UserSession'; export class UserApiRequestFactory extends BaseAPIRequestFactory { /** + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. * @param authCodeResponse */ - public async authHandler(authCodeResponse: AuthCodeResponse, _options?: Configuration): Promise { + public async authHandler(redirectUri: string, authCodeResponse: AuthCodeResponse, _options?: Configuration): Promise { let _config = _options || this.configuration; + // verify required parameter 'redirectUri' is not null or undefined + if (redirectUri === null || redirectUri === undefined) { + throw new RequiredError("UserApi", "authHandler", "redirectUri"); + } + + // verify required parameter 'authCodeResponse' is not null or undefined if (authCodeResponse === null || authCodeResponse === undefined) { throw new RequiredError("UserApi", "authHandler", "authCodeResponse"); @@ -30,12 +37,17 @@ export class UserApiRequestFactory extends BaseAPIRequestFactory { // Path Params - const localVarPath = '/auth'; + const localVarPath = '/auth/accessTokenLogin'; // Make Request Context const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.POST); requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") + // Query Params + if (redirectUri !== undefined) { + requestContext.setQueryParam("redirectUri", ObjectSerializer.serialize(redirectUri, "string", "uri")); + } + // Body Params const contentType = ObjectSerializer.getPreferredMediaType([ @@ -49,6 +61,41 @@ export class UserApiRequestFactory extends BaseAPIRequestFactory { requestContext.setBody(serializedBody); + const defaultAuth: SecurityAuthentication | undefined = _config?.authMethods?.default + if (defaultAuth?.applySecurityAuthentication) { + await defaultAuth?.applySecurityAuthentication(requestContext); + } + + return requestContext; + } + + /** + * Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. + */ + public async authRequestUrlHandler(redirectUri: string, _options?: Configuration): Promise { + let _config = _options || this.configuration; + + // verify required parameter 'redirectUri' is not null or undefined + if (redirectUri === null || redirectUri === undefined) { + throw new RequiredError("UserApi", "authRequestUrlHandler", "redirectUri"); + } + + + // Path Params + const localVarPath = '/auth/authenticationRequestUrl'; + + // Make Request Context + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); + requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") + + // Query Params + if (redirectUri !== undefined) { + requestContext.setQueryParam("redirectUri", ObjectSerializer.serialize(redirectUri, "string", "uri")); + } + + + const defaultAuth: SecurityAuthentication | undefined = _config?.authMethods?.default if (defaultAuth?.applySecurityAuthentication) { await defaultAuth?.applySecurityAuthentication(requestContext); @@ -97,4 +144,40 @@ export class UserApiResponseProcessor { throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); } + /** + * Unwraps the actual response sent by the server from the response context and deserializes the response content + * to the expected objects + * + * @params response Response returned by the server for a request to authRequestUrlHandler + * @throws ApiException if the response code was not in [200, 299] + */ + public async authRequestUrlHandlerWithHttpInfo(response: ResponseContext): Promise> { + const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); + if (isCodeInRange("200", response.httpStatusCode)) { + const body: string = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "string", "uri" + ) as string; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + if (isCodeInRange("500", response.httpStatusCode)) { + const body: Exception = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "Exception", "uri" + ) as Exception; + throw new ApiException(response.httpStatusCode, "A server error occurred.", body, response.headers); + } + + // Work around for missing responses in specification, e.g. for petstore.yaml + if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { + const body: string = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "string", "uri" + ) as string; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + + throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); + } + } diff --git a/api-client/typescript/docs/ProcessesApi.md b/api-client/typescript/docs/ProcessesApi.md index c8db807..ba66336 100644 --- a/api-client/typescript/docs/ProcessesApi.md +++ b/api-client/typescript/docs/ProcessesApi.md @@ -69,7 +69,7 @@ No authorization required [[Back to top]](#) [[Back to API list]](README.md#documentation-for-api-endpoints) [[Back to Model list]](README.md#documentation-for-models) [[Back to README]](README.md) # **executeNdvi** -> NDVIProcessOutputs executeNdvi(nDVIProcessInputs) +> NDVIProcessOutputs executeNdvi(nDVIProcessParams) ### Example @@ -84,18 +84,24 @@ const apiInstance = new ProcessesApi(configuration); const request: ProcessesApiExecuteNdviRequest = { - nDVIProcessInputs: { - coordinate: { - value: { - type: "Point", - coordinates: [ - 3.14, - ], + nDVIProcessParams: { + inputs: { + coordinate: { + value: { + type: "Point", + coordinates: [ + 3.14, + ], + }, + mediaType: "application/geo+json", }, - mediaType: "application/geo+json", + year: 0, + month: 0, + }, + outputs: { + "key": null, }, - year: 0, - month: 0, + response: "raw", }, }; @@ -108,7 +114,7 @@ console.log('API called successfully. Returned data:', data); Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- - **nDVIProcessInputs** | **NDVIProcessInputs**| | + **nDVIProcessParams** | **NDVIProcessParams**| | ### Return type @@ -133,7 +139,7 @@ No authorization required [[Back to top]](#) [[Back to API list]](README.md#documentation-for-api-endpoints) [[Back to Model list]](README.md#documentation-for-models) [[Back to README]](README.md) # **execution** -> { [key: string]: InlineOrRefData; } execution(execute) +> Results execution(execute) Create a new job. For more information, see [Section 7.11](https://docs.ogc.org/is/18-062/18-062.html#sc_create_job). @@ -189,7 +195,7 @@ Name | Type | Description | Notes ### Return type -**{ [key: string]: InlineOrRefData; }** +**Results** ### Authorization @@ -219,11 +225,17 @@ For more information, see [Section 11](https://docs.ogc.org/is/18-062/18-062.htm ```typescript import { createConfiguration, ProcessesApi } from ''; +import type { ProcessesApiJobsRequest } from ''; const configuration = createConfiguration(); const apiInstance = new ProcessesApi(configuration); -const request = {}; +const request: ProcessesApiJobsRequest = { + // Amount of items to return (optional) + limit: 0, + // Offset into the items list (optional) + offset: 0, +}; const data = await apiInstance.jobs(request); console.log('API called successfully. Returned data:', data); @@ -231,7 +243,11 @@ console.log('API called successfully. Returned data:', data); ### Parameters -This endpoint does not need any parameter. + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **limit** | [**number**] | Amount of items to return | (optional) defaults to undefined + **offset** | [**number**] | Offset into the items list | (optional) defaults to undefined ### Return type @@ -358,7 +374,7 @@ No authorization required [[Back to top]](#) [[Back to API list]](README.md#documentation-for-api-endpoints) [[Back to Model list]](README.md#documentation-for-models) [[Back to README]](README.md) # **results** -> { [key: string]: InlineOrRefData; } results() +> Results results() Lists available results of a job. In case of a failure, lists exceptions instead. For more information, see [Section 7.13](https://docs.ogc.org/is/18-062r2/18-062r2.html#sc_retrieve_job_results). @@ -391,7 +407,7 @@ Name | Type | Description | Notes ### Return type -**{ [key: string]: InlineOrRefData; }** +**Results** ### Authorization diff --git a/api-client/typescript/docs/UserApi.md b/api-client/typescript/docs/UserApi.md index eac9813..809069c 100644 --- a/api-client/typescript/docs/UserApi.md +++ b/api-client/typescript/docs/UserApi.md @@ -4,7 +4,8 @@ All URIs are relative to *http://localhost* Method | HTTP request | Description ------------- | ------------- | ------------- -[**authHandler**](UserApi.md#authHandler) | **POST** /auth | +[**authHandler**](UserApi.md#authHandler) | **POST** /auth/accessTokenLogin | +[**authRequestUrlHandler**](UserApi.md#authRequestUrlHandler) | **GET** /auth/authenticationRequestUrl | Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. # **authHandler** @@ -22,6 +23,8 @@ const configuration = createConfiguration(); const apiInstance = new UserApi(configuration); const request: UserApiAuthHandlerRequest = { + // The URI to which the identity provider should redirect after successful authentication. + redirectUri: "redirectUri_example", authCodeResponse: { code: "code_example", @@ -40,6 +43,7 @@ console.log('API called successfully. Returned data:', data); Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **authCodeResponse** | **AuthCodeResponse**| | + **redirectUri** | [**string**] | The URI to which the identity provider should redirect after successful authentication. | defaults to undefined ### Return type @@ -64,4 +68,57 @@ No authorization required [[Back to top]](#) [[Back to API list]](README.md#documentation-for-api-endpoints) [[Back to Model list]](README.md#documentation-for-models) [[Back to README]](README.md) +# **authRequestUrlHandler** +> string authRequestUrlHandler() + + +### Example + + +```typescript +import { createConfiguration, UserApi } from ''; +import type { UserApiAuthRequestUrlHandlerRequest } from ''; + +const configuration = createConfiguration(); +const apiInstance = new UserApi(configuration); + +const request: UserApiAuthRequestUrlHandlerRequest = { + // The URI to which the identity provider should redirect after successful authentication. + redirectUri: "redirectUri_example", +}; + +const data = await apiInstance.authRequestUrlHandler(request); +console.log('API called successfully. Returned data:', data); +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **redirectUri** | [**string**] | The URI to which the identity provider should redirect after successful authentication. | defaults to undefined + + +### Return type + +**string** + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: text/plain, application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | A URL for initiating the OIDC code flow. | - | +**500** | A server error occurred. | - | + +[[Back to top]](#) [[Back to API list]](README.md#documentation-for-api-endpoints) [[Back to Model list]](README.md#documentation-for-models) [[Back to README]](README.md) + diff --git a/api-client/typescript/models/NDVIProcessOutputs.ts b/api-client/typescript/models/NDVIProcessOutputs.ts index ff16bf8..606b0e7 100644 --- a/api-client/typescript/models/NDVIProcessOutputs.ts +++ b/api-client/typescript/models/NDVIProcessOutputs.ts @@ -29,7 +29,7 @@ export class NDVIProcessOutputs { }, { "name": "kNdvi", - "baseName": "k_ndvi", + "baseName": "kNdvi", "type": "number", "format": "double" } ]; diff --git a/api-client/typescript/models/NDVIProcessParams.ts b/api-client/typescript/models/NDVIProcessParams.ts new file mode 100644 index 0000000..de56b5f --- /dev/null +++ b/api-client/typescript/models/NDVIProcessParams.ts @@ -0,0 +1,57 @@ +/** + * BioIS API + * API for the BioIS service, providing access to geospatial processing and job management. + * + * OpenAPI spec version: 0.1.0 + * Contact: info@geoengine.de + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { NDVIProcessInputs } from '../models/NDVIProcessInputs'; +import { Response } from '../models/Response'; +import { HttpFile } from '../http/http'; + +/** +* Process execution +*/ +export class NDVIProcessParams { + 'inputs': NDVIProcessInputs; + 'outputs'?: { [key: string]: any; }; + 'response'?: Response; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "inputs", + "baseName": "inputs", + "type": "NDVIProcessInputs", + "format": "" + }, + { + "name": "outputs", + "baseName": "outputs", + "type": "{ [key: string]: any; }", + "format": "" + }, + { + "name": "response", + "baseName": "response", + "type": "Response", + "format": "" + } ]; + + static getAttributeTypeMap() { + return NDVIProcessParams.attributeTypeMap; + } + + public constructor() { + } +} + + diff --git a/api-client/typescript/models/ObjectSerializer.ts b/api-client/typescript/models/ObjectSerializer.ts index f76188b..9855210 100644 --- a/api-client/typescript/models/ObjectSerializer.ts +++ b/api-client/typescript/models/ObjectSerializer.ts @@ -20,6 +20,7 @@ export * from '../models/MaxOccurs'; export * from '../models/Metadata'; export * from '../models/NDVIProcessInputs'; export * from '../models/NDVIProcessOutputs'; +export * from '../models/NDVIProcessParams'; export * from '../models/Output'; export * from '../models/OutputDescription'; export * from '../models/PointGeoJson'; @@ -31,6 +32,7 @@ export * from '../models/ProcessList'; export * from '../models/ProcessSummary'; export * from '../models/QualifiedInputValue'; export * from '../models/Response'; +export * from '../models/Results'; export * from '../models/Schema'; export * from '../models/StatusCode'; export * from '../models/StatusInfo'; @@ -61,6 +63,7 @@ import { MaxOccursClass } from '../models/MaxOccurs'; import { Metadata } from '../models/Metadata'; import { NDVIProcessInputs } from '../models/NDVIProcessInputs'; import { NDVIProcessOutputs } from '../models/NDVIProcessOutputs'; +import { NDVIProcessParams } from '../models/NDVIProcessParams'; import { Output } from '../models/Output'; import { OutputDescription } from '../models/OutputDescription'; import { PointGeoJson } from '../models/PointGeoJson'; @@ -72,6 +75,7 @@ import { ProcessList } from '../models/ProcessList'; import { ProcessSummary } from '../models/ProcessSummary'; import { QualifiedInputValue } from '../models/QualifiedInputValue'; import { Response } from '../models/Response'; +import { ResultsClass } from '../models/Results'; import { SchemaClass } from '../models/Schema'; import { StatusCode } from '../models/StatusCode'; import { StatusInfo } from '../models/StatusInfo'; @@ -123,6 +127,7 @@ let typeMap: {[index: string]: any} = { "Metadata": Metadata, "NDVIProcessInputs": NDVIProcessInputs, "NDVIProcessOutputs": NDVIProcessOutputs, + "NDVIProcessParams": NDVIProcessParams, "Output": Output, "OutputDescription": OutputDescription, "PointGeoJson": PointGeoJson, diff --git a/api-client/typescript/models/Results.ts b/api-client/typescript/models/Results.ts new file mode 100644 index 0000000..d136abd --- /dev/null +++ b/api-client/typescript/models/Results.ts @@ -0,0 +1,32 @@ +/** + * BioIS API + * API for the BioIS service, providing access to geospatial processing and job management. + * + * OpenAPI spec version: 0.1.0 + * Contact: info@geoengine.de + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; +import { InlineOrRefData } from './InlineOrRefData'; + +/** + * @type Results + * Type + * @export + */ +export type Results = HttpFile | { [key: string]: InlineOrRefData; }; + +/** +* @type ResultsClass +* @export +*/ +export class ResultsClass { + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; +} + diff --git a/api-client/typescript/models/all.ts b/api-client/typescript/models/all.ts index 0cae130..b275021 100644 --- a/api-client/typescript/models/all.ts +++ b/api-client/typescript/models/all.ts @@ -20,6 +20,7 @@ export * from '../models/MaxOccurs' export * from '../models/Metadata' export * from '../models/NDVIProcessInputs' export * from '../models/NDVIProcessOutputs' +export * from '../models/NDVIProcessParams' export * from '../models/Output' export * from '../models/OutputDescription' export * from '../models/PointGeoJson' @@ -31,6 +32,7 @@ export * from '../models/ProcessList' export * from '../models/ProcessSummary' export * from '../models/QualifiedInputValue' export * from '../models/Response' +export * from '../models/Results' export * from '../models/Schema' export * from '../models/StatusCode' export * from '../models/StatusInfo' diff --git a/api-client/typescript/types/ObjectParamAPI.ts b/api-client/typescript/types/ObjectParamAPI.ts index 5eb96a9..ba1d0b1 100644 --- a/api-client/typescript/types/ObjectParamAPI.ts +++ b/api-client/typescript/types/ObjectParamAPI.ts @@ -24,6 +24,7 @@ import { MaxOccurs } from '../models/MaxOccurs'; import { Metadata } from '../models/Metadata'; import { NDVIProcessInputs } from '../models/NDVIProcessInputs'; import { NDVIProcessOutputs } from '../models/NDVIProcessOutputs'; +import { NDVIProcessParams } from '../models/NDVIProcessParams'; import { Output } from '../models/Output'; import { OutputDescription } from '../models/OutputDescription'; import { PointGeoJson } from '../models/PointGeoJson'; @@ -35,6 +36,7 @@ import { ProcessList } from '../models/ProcessList'; import { ProcessSummary } from '../models/ProcessSummary'; import { QualifiedInputValue } from '../models/QualifiedInputValue'; import { Response } from '../models/Response'; +import { Results } from '../models/Results'; import { Schema } from '../models/Schema'; import { StatusCode } from '../models/StatusCode'; import { StatusInfo } from '../models/StatusInfo'; @@ -161,10 +163,10 @@ export interface ProcessesApiDeleteRequest { export interface ProcessesApiExecuteNdviRequest { /** * - * @type NDVIProcessInputs + * @type NDVIProcessParams * @memberof ProcessesApiexecuteNdvi */ - nDVIProcessInputs: NDVIProcessInputs + nDVIProcessParams: NDVIProcessParams } export interface ProcessesApiExecutionRequest { @@ -184,6 +186,22 @@ export interface ProcessesApiExecutionRequest { } export interface ProcessesApiJobsRequest { + /** + * Amount of items to return + * Minimum: 0 + * Defaults to: undefined + * @type number + * @memberof ProcessesApijobs + */ + limit?: number + /** + * Offset into the items list + * Minimum: 0 + * Defaults to: undefined + * @type number + * @memberof ProcessesApijobs + */ + offset?: number } export interface ProcessesApiProcessRequest { @@ -248,14 +266,14 @@ export class ObjectProcessesApi { * @param param the request object */ public executeNdviWithHttpInfo(param: ProcessesApiExecuteNdviRequest, options?: ConfigurationOptions): Promise> { - return this.api.executeNdviWithHttpInfo(param.nDVIProcessInputs, options).toPromise(); + return this.api.executeNdviWithHttpInfo(param.nDVIProcessParams, options).toPromise(); } /** * @param param the request object */ public executeNdvi(param: ProcessesApiExecuteNdviRequest, options?: ConfigurationOptions): Promise { - return this.api.executeNdvi(param.nDVIProcessInputs, options).toPromise(); + return this.api.executeNdvi(param.nDVIProcessParams, options).toPromise(); } /** @@ -263,7 +281,7 @@ export class ObjectProcessesApi { * Execute a process * @param param the request object */ - public executionWithHttpInfo(param: ProcessesApiExecutionRequest, options?: ConfigurationOptions): Promise> { + public executionWithHttpInfo(param: ProcessesApiExecutionRequest, options?: ConfigurationOptions): Promise> { return this.api.executionWithHttpInfo(param.processID, param.execute, options).toPromise(); } @@ -272,7 +290,7 @@ export class ObjectProcessesApi { * Execute a process * @param param the request object */ - public execution(param: ProcessesApiExecutionRequest, options?: ConfigurationOptions): Promise<{ [key: string]: InlineOrRefData; }> { + public execution(param: ProcessesApiExecutionRequest, options?: ConfigurationOptions): Promise { return this.api.execution(param.processID, param.execute, options).toPromise(); } @@ -282,7 +300,7 @@ export class ObjectProcessesApi { * @param param the request object */ public jobsWithHttpInfo(param: ProcessesApiJobsRequest = {}, options?: ConfigurationOptions): Promise> { - return this.api.jobsWithHttpInfo( options).toPromise(); + return this.api.jobsWithHttpInfo(param.limit, param.offset, options).toPromise(); } /** @@ -291,7 +309,7 @@ export class ObjectProcessesApi { * @param param the request object */ public jobs(param: ProcessesApiJobsRequest = {}, options?: ConfigurationOptions): Promise { - return this.api.jobs( options).toPromise(); + return this.api.jobs(param.limit, param.offset, options).toPromise(); } /** @@ -335,7 +353,7 @@ export class ObjectProcessesApi { * Retrieve the result(s) of a job * @param param the request object */ - public resultsWithHttpInfo(param: ProcessesApiResultsRequest, options?: ConfigurationOptions): Promise> { + public resultsWithHttpInfo(param: ProcessesApiResultsRequest, options?: ConfigurationOptions): Promise> { return this.api.resultsWithHttpInfo(param.jobId, options).toPromise(); } @@ -344,7 +362,7 @@ export class ObjectProcessesApi { * Retrieve the result(s) of a job * @param param the request object */ - public results(param: ProcessesApiResultsRequest, options?: ConfigurationOptions): Promise<{ [key: string]: InlineOrRefData; }> { + public results(param: ProcessesApiResultsRequest, options?: ConfigurationOptions): Promise { return this.api.results(param.jobId, options).toPromise(); } @@ -372,6 +390,13 @@ import { ObservableUserApi } from "./ObservableAPI"; import { UserApiRequestFactory, UserApiResponseProcessor} from "../apis/UserApi"; export interface UserApiAuthHandlerRequest { + /** + * The URI to which the identity provider should redirect after successful authentication. + * Defaults to: undefined + * @type string + * @memberof UserApiauthHandler + */ + redirectUri: string /** * * @type AuthCodeResponse @@ -380,6 +405,16 @@ export interface UserApiAuthHandlerRequest { authCodeResponse: AuthCodeResponse } +export interface UserApiAuthRequestUrlHandlerRequest { + /** + * The URI to which the identity provider should redirect after successful authentication. + * Defaults to: undefined + * @type string + * @memberof UserApiauthRequestUrlHandler + */ + redirectUri: string +} + export class ObjectUserApi { private api: ObservableUserApi @@ -391,14 +426,30 @@ export class ObjectUserApi { * @param param the request object */ public authHandlerWithHttpInfo(param: UserApiAuthHandlerRequest, options?: ConfigurationOptions): Promise> { - return this.api.authHandlerWithHttpInfo(param.authCodeResponse, options).toPromise(); + return this.api.authHandlerWithHttpInfo(param.redirectUri, param.authCodeResponse, options).toPromise(); } /** * @param param the request object */ public authHandler(param: UserApiAuthHandlerRequest, options?: ConfigurationOptions): Promise { - return this.api.authHandler(param.authCodeResponse, options).toPromise(); + return this.api.authHandler(param.redirectUri, param.authCodeResponse, options).toPromise(); + } + + /** + * Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. + * @param param the request object + */ + public authRequestUrlHandlerWithHttpInfo(param: UserApiAuthRequestUrlHandlerRequest, options?: ConfigurationOptions): Promise> { + return this.api.authRequestUrlHandlerWithHttpInfo(param.redirectUri, options).toPromise(); + } + + /** + * Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. + * @param param the request object + */ + public authRequestUrlHandler(param: UserApiAuthRequestUrlHandlerRequest, options?: ConfigurationOptions): Promise { + return this.api.authRequestUrlHandler(param.redirectUri, options).toPromise(); } } diff --git a/api-client/typescript/types/ObservableAPI.ts b/api-client/typescript/types/ObservableAPI.ts index e26fccb..10f1c9e 100644 --- a/api-client/typescript/types/ObservableAPI.ts +++ b/api-client/typescript/types/ObservableAPI.ts @@ -25,6 +25,7 @@ import { MaxOccurs } from '../models/MaxOccurs'; import { Metadata } from '../models/Metadata'; import { NDVIProcessInputs } from '../models/NDVIProcessInputs'; import { NDVIProcessOutputs } from '../models/NDVIProcessOutputs'; +import { NDVIProcessParams } from '../models/NDVIProcessParams'; import { Output } from '../models/Output'; import { OutputDescription } from '../models/OutputDescription'; import { PointGeoJson } from '../models/PointGeoJson'; @@ -36,6 +37,7 @@ import { ProcessList } from '../models/ProcessList'; import { ProcessSummary } from '../models/ProcessSummary'; import { QualifiedInputValue } from '../models/QualifiedInputValue'; import { Response } from '../models/Response'; +import { Results } from '../models/Results'; import { Schema } from '../models/Schema'; import { StatusCode } from '../models/StatusCode'; import { StatusInfo } from '../models/StatusInfo'; @@ -253,12 +255,12 @@ export class ObservableProcessesApi { } /** - * @param nDVIProcessInputs + * @param nDVIProcessParams */ - public executeNdviWithHttpInfo(nDVIProcessInputs: NDVIProcessInputs, _options?: ConfigurationOptions): Observable> { + public executeNdviWithHttpInfo(nDVIProcessParams: NDVIProcessParams, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.executeNdvi(nDVIProcessInputs, _config); + const requestContextPromise = this.requestFactory.executeNdvi(nDVIProcessParams, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -276,10 +278,10 @@ export class ObservableProcessesApi { } /** - * @param nDVIProcessInputs + * @param nDVIProcessParams */ - public executeNdvi(nDVIProcessInputs: NDVIProcessInputs, _options?: ConfigurationOptions): Observable { - return this.executeNdviWithHttpInfo(nDVIProcessInputs, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public executeNdvi(nDVIProcessParams: NDVIProcessParams, _options?: ConfigurationOptions): Observable { + return this.executeNdviWithHttpInfo(nDVIProcessParams, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** @@ -288,7 +290,7 @@ export class ObservableProcessesApi { * @param processID * @param execute */ - public executionWithHttpInfo(processID: string, execute: Execute, _options?: ConfigurationOptions): Observable> { + public executionWithHttpInfo(processID: string, execute: Execute, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); const requestContextPromise = this.requestFactory.execution(processID, execute, _config); @@ -314,18 +316,20 @@ export class ObservableProcessesApi { * @param processID * @param execute */ - public execution(processID: string, execute: Execute, _options?: ConfigurationOptions): Observable<{ [key: string]: InlineOrRefData; }> { - return this.executionWithHttpInfo(processID, execute, _options).pipe(map((apiResponse: HttpInfo<{ [key: string]: InlineOrRefData; }>) => apiResponse.data)); + public execution(processID: string, execute: Execute, _options?: ConfigurationOptions): Observable { + return this.executionWithHttpInfo(processID, execute, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** * For more information, see [Section 11](https://docs.ogc.org/is/18-062/18-062.html#sc_job_list). * Retrieve the list of jobs + * @param [limit] Amount of items to return + * @param [offset] Offset into the items list */ - public jobsWithHttpInfo(_options?: ConfigurationOptions): Observable> { + public jobsWithHttpInfo(limit?: number, offset?: number, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.jobs(_config); + const requestContextPromise = this.requestFactory.jobs(limit, offset, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -345,9 +349,11 @@ export class ObservableProcessesApi { /** * For more information, see [Section 11](https://docs.ogc.org/is/18-062/18-062.html#sc_job_list). * Retrieve the list of jobs + * @param [limit] Amount of items to return + * @param [offset] Offset into the items list */ - public jobs(_options?: ConfigurationOptions): Observable { - return this.jobsWithHttpInfo(_options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public jobs(limit?: number, offset?: number, _options?: ConfigurationOptions): Observable { + return this.jobsWithHttpInfo(limit, offset, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** @@ -421,7 +427,7 @@ export class ObservableProcessesApi { * Retrieve the result(s) of a job * @param jobId */ - public resultsWithHttpInfo(jobId: string, _options?: ConfigurationOptions): Observable> { + public resultsWithHttpInfo(jobId: string, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); const requestContextPromise = this.requestFactory.results(jobId, _config); @@ -446,8 +452,8 @@ export class ObservableProcessesApi { * Retrieve the result(s) of a job * @param jobId */ - public results(jobId: string, _options?: ConfigurationOptions): Observable<{ [key: string]: InlineOrRefData; }> { - return this.resultsWithHttpInfo(jobId, _options).pipe(map((apiResponse: HttpInfo<{ [key: string]: InlineOrRefData; }>) => apiResponse.data)); + public results(jobId: string, _options?: ConfigurationOptions): Observable { + return this.resultsWithHttpInfo(jobId, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } /** @@ -503,12 +509,13 @@ export class ObservableUserApi { } /** + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. * @param authCodeResponse */ - public authHandlerWithHttpInfo(authCodeResponse: AuthCodeResponse, _options?: ConfigurationOptions): Observable> { + public authHandlerWithHttpInfo(redirectUri: string, authCodeResponse: AuthCodeResponse, _options?: ConfigurationOptions): Observable> { const _config = mergeConfiguration(this.configuration, _options); - const requestContextPromise = this.requestFactory.authHandler(authCodeResponse, _config); + const requestContextPromise = this.requestFactory.authHandler(redirectUri, authCodeResponse, _config); // build promise chain let middlewarePreObservable = from(requestContextPromise); for (const middleware of _config.middleware) { @@ -526,10 +533,43 @@ export class ObservableUserApi { } /** + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. * @param authCodeResponse */ - public authHandler(authCodeResponse: AuthCodeResponse, _options?: ConfigurationOptions): Observable { - return this.authHandlerWithHttpInfo(authCodeResponse, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + public authHandler(redirectUri: string, authCodeResponse: AuthCodeResponse, _options?: ConfigurationOptions): Observable { + return this.authHandlerWithHttpInfo(redirectUri, authCodeResponse, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + } + + /** + * Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. + */ + public authRequestUrlHandlerWithHttpInfo(redirectUri: string, _options?: ConfigurationOptions): Observable> { + const _config = mergeConfiguration(this.configuration, _options); + + const requestContextPromise = this.requestFactory.authRequestUrlHandler(redirectUri, _config); + // build promise chain + let middlewarePreObservable = from(requestContextPromise); + for (const middleware of _config.middleware) { + middlewarePreObservable = middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => middleware.pre(ctx))); + } + + return middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => _config.httpApi.send(ctx))). + pipe(mergeMap((response: ResponseContext) => { + let middlewarePostObservable = of(response); + for (const middleware of _config.middleware.reverse()) { + middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); + } + return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.authRequestUrlHandlerWithHttpInfo(rsp))); + })); + } + + /** + * Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. + */ + public authRequestUrlHandler(redirectUri: string, _options?: ConfigurationOptions): Observable { + return this.authRequestUrlHandlerWithHttpInfo(redirectUri, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } } diff --git a/api-client/typescript/types/PromiseAPI.ts b/api-client/typescript/types/PromiseAPI.ts index efbcb63..f3e41f4 100644 --- a/api-client/typescript/types/PromiseAPI.ts +++ b/api-client/typescript/types/PromiseAPI.ts @@ -24,6 +24,7 @@ import { MaxOccurs } from '../models/MaxOccurs'; import { Metadata } from '../models/Metadata'; import { NDVIProcessInputs } from '../models/NDVIProcessInputs'; import { NDVIProcessOutputs } from '../models/NDVIProcessOutputs'; +import { NDVIProcessParams } from '../models/NDVIProcessParams'; import { Output } from '../models/Output'; import { OutputDescription } from '../models/OutputDescription'; import { PointGeoJson } from '../models/PointGeoJson'; @@ -35,6 +36,7 @@ import { ProcessList } from '../models/ProcessList'; import { ProcessSummary } from '../models/ProcessSummary'; import { QualifiedInputValue } from '../models/QualifiedInputValue'; import { Response } from '../models/Response'; +import { Results } from '../models/Results'; import { Schema } from '../models/Schema'; import { StatusCode } from '../models/StatusCode'; import { StatusInfo } from '../models/StatusInfo'; @@ -191,20 +193,20 @@ export class PromiseProcessesApi { } /** - * @param nDVIProcessInputs + * @param nDVIProcessParams */ - public executeNdviWithHttpInfo(nDVIProcessInputs: NDVIProcessInputs, _options?: PromiseConfigurationOptions): Promise> { + public executeNdviWithHttpInfo(nDVIProcessParams: NDVIProcessParams, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); - const result = this.api.executeNdviWithHttpInfo(nDVIProcessInputs, observableOptions); + const result = this.api.executeNdviWithHttpInfo(nDVIProcessParams, observableOptions); return result.toPromise(); } /** - * @param nDVIProcessInputs + * @param nDVIProcessParams */ - public executeNdvi(nDVIProcessInputs: NDVIProcessInputs, _options?: PromiseConfigurationOptions): Promise { + public executeNdvi(nDVIProcessParams: NDVIProcessParams, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); - const result = this.api.executeNdvi(nDVIProcessInputs, observableOptions); + const result = this.api.executeNdvi(nDVIProcessParams, observableOptions); return result.toPromise(); } @@ -214,7 +216,7 @@ export class PromiseProcessesApi { * @param processID * @param execute */ - public executionWithHttpInfo(processID: string, execute: Execute, _options?: PromiseConfigurationOptions): Promise> { + public executionWithHttpInfo(processID: string, execute: Execute, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); const result = this.api.executionWithHttpInfo(processID, execute, observableOptions); return result.toPromise(); @@ -226,7 +228,7 @@ export class PromiseProcessesApi { * @param processID * @param execute */ - public execution(processID: string, execute: Execute, _options?: PromiseConfigurationOptions): Promise<{ [key: string]: InlineOrRefData; }> { + public execution(processID: string, execute: Execute, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); const result = this.api.execution(processID, execute, observableOptions); return result.toPromise(); @@ -235,20 +237,24 @@ export class PromiseProcessesApi { /** * For more information, see [Section 11](https://docs.ogc.org/is/18-062/18-062.html#sc_job_list). * Retrieve the list of jobs + * @param [limit] Amount of items to return + * @param [offset] Offset into the items list */ - public jobsWithHttpInfo(_options?: PromiseConfigurationOptions): Promise> { + public jobsWithHttpInfo(limit?: number, offset?: number, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); - const result = this.api.jobsWithHttpInfo(observableOptions); + const result = this.api.jobsWithHttpInfo(limit, offset, observableOptions); return result.toPromise(); } /** * For more information, see [Section 11](https://docs.ogc.org/is/18-062/18-062.html#sc_job_list). * Retrieve the list of jobs + * @param [limit] Amount of items to return + * @param [offset] Offset into the items list */ - public jobs(_options?: PromiseConfigurationOptions): Promise { + public jobs(limit?: number, offset?: number, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); - const result = this.api.jobs(observableOptions); + const result = this.api.jobs(limit, offset, observableOptions); return result.toPromise(); } @@ -299,7 +305,7 @@ export class PromiseProcessesApi { * Retrieve the result(s) of a job * @param jobId */ - public resultsWithHttpInfo(jobId: string, _options?: PromiseConfigurationOptions): Promise> { + public resultsWithHttpInfo(jobId: string, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); const result = this.api.resultsWithHttpInfo(jobId, observableOptions); return result.toPromise(); @@ -310,7 +316,7 @@ export class PromiseProcessesApi { * Retrieve the result(s) of a job * @param jobId */ - public results(jobId: string, _options?: PromiseConfigurationOptions): Promise<{ [key: string]: InlineOrRefData; }> { + public results(jobId: string, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); const result = this.api.results(jobId, observableOptions); return result.toPromise(); @@ -358,20 +364,42 @@ export class PromiseUserApi { } /** + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. * @param authCodeResponse */ - public authHandlerWithHttpInfo(authCodeResponse: AuthCodeResponse, _options?: PromiseConfigurationOptions): Promise> { + public authHandlerWithHttpInfo(redirectUri: string, authCodeResponse: AuthCodeResponse, _options?: PromiseConfigurationOptions): Promise> { const observableOptions = wrapOptions(_options); - const result = this.api.authHandlerWithHttpInfo(authCodeResponse, observableOptions); + const result = this.api.authHandlerWithHttpInfo(redirectUri, authCodeResponse, observableOptions); return result.toPromise(); } /** + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. * @param authCodeResponse */ - public authHandler(authCodeResponse: AuthCodeResponse, _options?: PromiseConfigurationOptions): Promise { + public authHandler(redirectUri: string, authCodeResponse: AuthCodeResponse, _options?: PromiseConfigurationOptions): Promise { const observableOptions = wrapOptions(_options); - const result = this.api.authHandler(authCodeResponse, observableOptions); + const result = this.api.authHandler(redirectUri, authCodeResponse, observableOptions); + return result.toPromise(); + } + + /** + * Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. + */ + public authRequestUrlHandlerWithHttpInfo(redirectUri: string, _options?: PromiseConfigurationOptions): Promise> { + const observableOptions = wrapOptions(_options); + const result = this.api.authRequestUrlHandlerWithHttpInfo(redirectUri, observableOptions); + return result.toPromise(); + } + + /** + * Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. + * @param redirectUri The URI to which the identity provider should redirect after successful authentication. + */ + public authRequestUrlHandler(redirectUri: string, _options?: PromiseConfigurationOptions): Promise { + const observableOptions = wrapOptions(_options); + const result = this.api.authRequestUrlHandler(redirectUri, observableOptions); return result.toPromise(); } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5f667e9..08d08b4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "ogcapi" version = "0.3.1" -source = "git+https://github.com/georust/ogcapi?branch=main#7fc7f000b5163d3d15cbb43a7282d570a34de0b3" +source = "git+https://github.com/georust/ogcapi?branch=openapi-fixes#a7b3974bbcbc2a481694af163c6eaf96a4b9117c" dependencies = [ "ogcapi-drivers", "ogcapi-processes", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "ogcapi-drivers" version = "0.3.0" -source = "git+https://github.com/georust/ogcapi?branch=main#7fc7f000b5163d3d15cbb43a7282d570a34de0b3" +source = "git+https://github.com/georust/ogcapi?branch=openapi-fixes#a7b3974bbcbc2a481694af163c6eaf96a4b9117c" dependencies = [ "anyhow", "async-trait", @@ -2084,7 +2084,7 @@ dependencies = [ [[package]] name = "ogcapi-processes" version = "0.3.0" -source = "git+https://github.com/georust/ogcapi?branch=main#7fc7f000b5163d3d15cbb43a7282d570a34de0b3" +source = "git+https://github.com/georust/ogcapi?branch=openapi-fixes#a7b3974bbcbc2a481694af163c6eaf96a4b9117c" dependencies = [ "anyhow", "async-trait", @@ -2101,7 +2101,7 @@ dependencies = [ [[package]] name = "ogcapi-services" version = "0.3.0" -source = "git+https://github.com/georust/ogcapi?branch=main#7fc7f000b5163d3d15cbb43a7282d570a34de0b3" +source = "git+https://github.com/georust/ogcapi?branch=openapi-fixes#a7b3974bbcbc2a481694af163c6eaf96a4b9117c" dependencies = [ "anyhow", "axum", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "ogcapi-types" version = "0.3.0" -source = "git+https://github.com/georust/ogcapi?branch=main#7fc7f000b5163d3d15cbb43a7282d570a34de0b3" +source = "git+https://github.com/georust/ogcapi?branch=openapi-fixes#a7b3974bbcbc2a481694af163c6eaf96a4b9117c" dependencies = [ "chrono", "geojson", @@ -3697,7 +3697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f62640d..d121d5f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -63,7 +63,7 @@ futures = "0.3" geoengine-openapi-client = { git = "https://github.com/geo-engine/openapi-client", branch = "rust" } indoc = "2.0" nom = "8.0" -ogcapi = { git = "https://github.com/georust/ogcapi", branch = "main", default-features = false, features = [ +ogcapi = { git = "https://github.com/georust/ogcapi", branch = "openapi-fixes", default-features = false, features = [ "common", "drivers", "processes", diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 80b45a5..4a457d6 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -132,7 +132,7 @@ impl GeoEngineAuthMiddleware { "/processes/echo", "/processes/ndvi", ], - prefix: vec!["/api", "/swagger"], + prefix: vec!["/api", "/swagger", "/auth/"], }, } } diff --git a/backend/src/handler.rs b/backend/src/handler.rs index 4bb845f..dbe8718 100644 --- a/backend/src/handler.rs +++ b/backend/src/handler.rs @@ -4,21 +4,25 @@ use axum::{ Json, extract::{Query, State}, http::StatusCode, - routing::MethodRouter, + response::IntoResponse, }; -use geoengine_openapi_client::apis::{configuration::Configuration, session_api::oidc_login}; -use ogcapi::{services as ogcapi_services, types::common::Exception}; -use utoipa::openapi::{Paths, RefOr, Schema}; -use utoipa_axum::routes; - -type Routes = ( - Vec<(String, RefOr)>, - Paths, - MethodRouter, -); - -pub fn routes() -> Routes { - routes!(health_handler, auth_handler) +use geoengine_openapi_client::apis::{ + configuration::Configuration, + session_api::{oidc_init, oidc_login}, +}; +use ogcapi::{ + services::{self as ogcapi_services}, + types::common::Exception, +}; +use serde::Deserialize; +use url::Url; +use utoipa::IntoParams; +use utoipa_axum::{router::OpenApiRouter, routes}; + +pub fn auth_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(auth_handler)) + .routes(routes!(auth_request_url_handler)) } #[utoipa::path(get, path = "/health", responses((status = NO_CONTENT)))] @@ -26,7 +30,8 @@ pub async fn health_handler() -> StatusCode { StatusCode::NO_CONTENT } -#[utoipa::path(post, path = "/auth", tag = "User", +#[utoipa::path(post, path = "/accessTokenLogin", tag = "User", + params(AuthRequestUrlParams), responses( ( status = OK, @@ -41,16 +46,68 @@ pub async fn health_handler() -> StatusCode { ) ) )] -pub async fn auth_handler( +async fn auth_handler( State(api_config): State, - Query(redirect_uri): Query, + Query(AuthRequestUrlParams { redirect_uri }): Query, Json(auth_code_response): Json, ) -> ogcapi_services::Result> { - let user_session = oidc_login(&api_config, &redirect_uri, auth_code_response.into()) + let user_session = oidc_login( + &api_config, + redirect_uri.as_str(), + auth_code_response.into(), + ) + .await + .context("Failed to perform OIDC login")?; + + Ok(Json(user_session.into())) +} + +#[derive(Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] +struct AuthRequestUrlParams { + /// The URI to which the identity provider should redirect after successful authentication. + redirect_uri: Url, +} + +/// Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider's login page. +#[utoipa::path(get, path = "/authenticationRequestUrl", tag = "User", + params(AuthRequestUrlParams), + responses( + ( + status = OK, + description = "A URL for initiating the OIDC code flow.", + body = Url + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "A server error occurred.", + body = Exception, + example = json!(Exception::new_from_status(500)) + ) + ) +)] +async fn auth_request_url_handler( + State(api_config): State, + Query(AuthRequestUrlParams { redirect_uri }): Query, +) -> ogcapi_services::Result { + let auth_code_flow_request_url = oidc_init(&api_config, redirect_uri.as_str()) .await .context("Failed to perform OIDC login")?; - Ok(Json(user_session.into())) + let auth_code_flow_request_url: Url = auth_code_flow_request_url + .url + .parse() + .context("Failed to parse OIDC authentication request URL")?; + + Ok(UrlResponse(auth_code_flow_request_url)) +} + +struct UrlResponse(Url); + +impl IntoResponse for UrlResponse { + fn into_response(self) -> axum::response::Response { + String::from(self.0).into_response() + } } #[cfg(test)] @@ -114,7 +171,14 @@ mod tests { }; // call handler - let res = auth_handler(State(api_config), Query(redirect), Json(auth_code_response)).await; + let res = auth_handler( + State(api_config), + Query(AuthRequestUrlParams { + redirect_uri: Url::parse(&redirect).unwrap(), + }), + Json(auth_code_response), + ) + .await; assert!(res.is_ok(), "expected Ok(UserSession) from auth_handler"); } diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index 9617991..bb68f8f 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -115,10 +115,13 @@ impl ogcapi::drivers::JobHandler for JobHandler { async fn status_list(&self, offset: usize, limit: usize) -> anyhow::Result> { let user = USER.try_get().context("missing authenticated user")?; - let result = model::StatusInfo::query() + let query = model::StatusInfo::query() .filter(jobs::user_id.eq(user.id)) + .order(jobs::updated.desc()) .offset(offset as i64) - .limit(limit as i64) + .limit(limit as i64); + + let result = query .load::(&mut self.connection().await?) .await .context("Failed to query job status list from database")?; diff --git a/backend/src/processes/ndvi.rs b/backend/src/processes/ndvi.rs index 7b0a550..0da5350 100644 --- a/backend/src/processes/ndvi.rs +++ b/backend/src/processes/ndvi.rs @@ -43,7 +43,9 @@ pub struct NDVIProcess; #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema)] pub struct NDVIProcessInputs { pub coordinate: PointGeoJsonInput, + #[schema(minimum = 2014, maximum = 2014)] pub year: Year, + #[schema(minimum = 1, maximum = 6)] pub month: Month, } @@ -94,12 +96,13 @@ pub enum PointGeoJsonType { } #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Copy, Clone)] -pub struct Year(#[schemars(range(min = 2014, max = 2014))] u16); +pub struct Year(u16); #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Copy, Clone)] -pub struct Month(#[schemars(range(min = 1, max = 6))] u16); +pub struct Month(u16); #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct NDVIProcessOutputs { pub ndvi: Option, pub k_ndvi: Option, @@ -122,7 +125,7 @@ impl From for ExecuteResults { } if let Some(k_ndvi) = outputs.k_ndvi { result.insert( - "k_ndvi".to_string(), + "kNdvi".to_string(), ExecuteResult { output: Output { format: None, @@ -246,7 +249,7 @@ impl Processor for NDVIProcess { }, ), ( - "k_ndvi".to_string(), + "kNdvi".to_string(), OutputDescription { description_type: DescriptionType { title: Some( @@ -290,7 +293,7 @@ impl Processor for NDVIProcess { for output_key in execute.outputs.keys() { match output_key.as_str() { "ndvi" => should_compute_ndvi = true, - "k_ndvi" => should_compute_k_ndvi = true, + "kNdvi" => should_compute_k_ndvi = true, other => anyhow::bail!("Unknown output requested: {other}"), } } @@ -648,7 +651,7 @@ mod tests { // outputs contain ndvi and k_ndvi assert!(process.outputs.contains_key("ndvi")); - assert!(process.outputs.contains_key("k_ndvi")); + assert!(process.outputs.contains_key("kNdvi")); // some basic checks for descriptions and schema presence let ndvi_output = &process.outputs["ndvi"]; diff --git a/backend/src/processes/path_info.rs b/backend/src/processes/path_info.rs index 58069bb..79c9e99 100644 --- a/backend/src/processes/path_info.rs +++ b/backend/src/processes/path_info.rs @@ -2,9 +2,26 @@ //! OpenAPI docs for processes. //! The functions are placeholders only. +use std::collections::HashMap; + use crate::processes::ndvi::{NDVIProcessInputs, NDVIProcessOutputs}; use axum::Json; -use utoipa::OpenApi; +use ogcapi::types::processes::Response; +use serde::Deserialize; +use utoipa::{OpenApi, ToSchema}; + +/// Process execution +#[allow(unused, reason = "Placeholder for spec only")] +// TODO: macro for generating this from the process definition +#[derive(Deserialize, ToSchema, Debug)] +pub struct NDVIProcessParams { + pub inputs: NDVIProcessInputs, + #[serde(default)] + #[allow(clippy::zero_sized_map_values, reason = "Placeholder for spec only")] + pub outputs: HashMap, + #[serde(default)] + pub response: Response, +} #[allow(unused, reason = "Placeholder for spec only")] #[utoipa::path( @@ -13,7 +30,7 @@ use utoipa::OpenApi; tag = "Processes", responses((status = OK, body = NDVIProcessOutputs)) )] -fn execute_ndvi(Json(_input): Json) {} +fn execute_ndvi(Json(_input): Json) {} /// OpenAPI extension to include process endpoints in the generated documentation #[allow(unused, reason = "Placeholder for spec only")] diff --git a/backend/src/server.rs b/backend/src/server.rs index 620610a..8b6a628 100644 --- a/backend/src/server.rs +++ b/backend/src/server.rs @@ -17,14 +17,15 @@ use utoipa::{ OpenApi as _, openapi::{ContactBuilder, OpenApi}, }; -use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::{router::OpenApiRouter, routes}; /// Create and configure the OGC API service, including routes, state, and OpenAPI documentation. pub async fn server() -> anyhow::Result { let db_pool = setup_db(&CONFIG.database).await?; let mut misc_router = OpenApiRouter::new() - .routes(handler::routes()) + .routes(routes!(handler::health_handler)) + .nest("/auth", handler::auth_router()) .with_state(CONFIG.geoengine.api_config(None)); misc_router diff --git a/frontend/README.md b/frontend/README.md index 0de7eb9..32d7211 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,56 +4,25 @@ The frontend is an Angular application that provides an interactive web interfac ## Development server -To start a local development server, run: +To start a local development server for the first time, run: ```bash -ng serve +just \ + install-api-client-deps \ + install-frontend-deps \ + run-frontend ``` Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. -## Code scaffolding - -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: - -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -## Building - -To build the project run: +Subsequent runs can be started with: ```bash -ng build + just run-frontend ``` -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: +or, to start both the backend and frontend together: ```bash -ng e2e + just run ``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/frontend/TODO.md b/frontend/TODO.md new file mode 100644 index 0000000..e71993b --- /dev/null +++ b/frontend/TODO.md @@ -0,0 +1,10 @@ +# ToDos for the frontend + +- [ ] Add unit tests for all non-GUI functions. +- [x] Add descriptions to NDVI create form, query from backend. +- [x] Display results in a user-friendly manner. +- [ ] Add error handling and display error messages to the user. +- [x] Fix job query with offset and limit as well as ExecuteResult in ogcapi. +- [x] Add refresh button to results page to update job status and results. +- [ ] Allow create new's coordinate input by using a map interface instead of manually entering coordinates. +- [ ] Show inputs with the results, e.g. show the input coordinates on a map. diff --git a/frontend/_theme-colors.scss b/frontend/_theme-colors.scss new file mode 100644 index 0000000..69c12d9 --- /dev/null +++ b/frontend/_theme-colors.scss @@ -0,0 +1,137 @@ +// This file was generated by running 'ng generate @angular/material:theme-color'. +// Proceed with caution if making changes to this file. + +@use 'sass:map'; +@use '@angular/material' as mat; + +// Note: Color palettes are generated from primary: #d7e4a5, secondary: #92d2e3, tertiary: #0e8e89 +$_palettes: ( + primary: ( + 0: #000000, + 10: #171e00, + 20: #2b3406, + 25: #353f11, + 30: #414b1b, + 35: #4c5726, + 40: #586331, + 50: #717c47, + 60: #8a965e, + 70: #a5b177, + 80: #c0cd90, + 90: #dce9aa, + 95: #eaf7b7, + 98: #f4ffc7, + 99: #fbffe0, + 100: #ffffff, + ), + secondary: ( + 0: #000000, + 10: #001f26, + 20: #003640, + 25: #00424e, + 30: #004e5c, + 35: #0c5a69, + 40: #206776, + 50: #3e808f, + 60: #5a9aaa, + 70: #75b5c5, + 80: #90d0e1, + 90: #acedfe, + 95: #d7f6ff, + 98: #f0fbff, + 99: #f8fdff, + 100: #ffffff, + ), + tertiary: ( + 0: #000000, + 10: #00201e, + 20: #003735, + 25: #004341, + 30: #00504d, + 35: #005d59, + 40: #006a66, + 50: #008580, + 60: #30a09b, + 70: #52bbb6, + 80: #70d7d1, + 90: #8df4ed, + 95: #b0fff9, + 98: #e3fffc, + 99: #f2fffd, + 100: #ffffff, + ), + neutral: ( + 0: #000000, + 10: #1b1c18, + 20: #30312c, + 25: #3c3c37, + 30: #474742, + 35: #53534d, + 40: #5f5f59, + 50: #787771, + 60: #92918b, + 70: #adaba5, + 80: #c8c6c0, + 90: #e4e2db, + 95: #f3f1e9, + 98: #fcf9f2, + 99: #fffcf5, + 100: #ffffff, + 4: #0e0f0b, + 6: #131410, + 12: #1f201c, + 17: #2a2a26, + 22: #353530, + 24: #393935, + 87: #dcdad3, + 92: #eae8e1, + 94: #f0eee6, + 96: #f6f3ec, + ), + neutral-variant: ( + 0: #000000, + 10: #1b1c13, + 20: #303127, + 25: #3b3c31, + 30: #46483c, + 35: #525348, + 40: #5e5f53, + 50: #77786b, + 60: #919284, + 70: #abac9e, + 80: #c7c7b9, + 90: #e3e3d4, + 95: #f2f2e2, + 98: #fafaea, + 99: #fdfded, + 100: #ffffff, + ), + error: ( + 0: #000000, + 10: #410002, + 20: #690005, + 25: #7e0007, + 30: #93000a, + 35: #a80710, + 40: #ba1a1a, + 50: #de3730, + 60: #ff5449, + 70: #ff897d, + 80: #ffb4ab, + 90: #ffdad6, + 95: #ffedea, + 98: #fff8f7, + 99: #fffbff, + 100: #ffffff, + ), +); + +$_rest: ( + secondary: map.get($_palettes, secondary), + neutral: map.get($_palettes, neutral), + neutral-variant: map.get($_palettes, neutral-variant), + error: map.get($_palettes, error), +); + +$primary-palette: map.merge(map.get($_palettes, primary), $_rest); +$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest); diff --git a/frontend/angular.json b/frontend/angular.json index 4b7898f..c004b0b 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -11,6 +11,7 @@ "projectType": "application", "schematics": { "@schematics/angular:component": { + "changeDetection": "OnPush", "style": "scss" } }, @@ -30,7 +31,11 @@ "input": "public" } ], - "styles": ["src/styles.scss"] + "styles": ["src/styles.scss"], + "loader": { + ".woff": "file", + ".woff2": "file" + } }, "configurations": { "production": { diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index a870aa0..dac5a43 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -4,33 +4,157 @@ const { defineConfig } = require('eslint/config'); const tseslint = require('typescript-eslint'); const angular = require('angular-eslint'); +const appPrefix = 'app'; + module.exports = defineConfig([ { files: ['**/*.ts'], extends: [ eslint.configs.recommended, - tseslint.configs.recommended, - tseslint.configs.stylistic, + tseslint.configs.recommendedTypeChecked, + tseslint.configs.stylisticTypeChecked, angular.configs.tsRecommended, ], processor: angular.processInlineTemplates, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + // project: true, + project: ['tsconfig.app.json', 'tsconfig.spec.json'], // Explicitly list Angular's configs + tsconfigRootDir: __dirname, // Recommended to ensure it resolves correctly from the project root + }, + }, rules: { + 'arrow-parens': ['off', 'always'], + 'brace-style': ['off', '1tbs'], // prettier is currently inconsistent, re-enable if possible + 'guard-for-in': 'error', + 'id-blacklist': 'off', + 'id-match': 'off', + 'import/order': 'off', + 'no-redeclare': ['error', { builtinGlobals: false }], + 'no-underscore-dangle': 'off', + 'valid-typeof': 'error', + 'no-bitwise': 'error', + 'no-empty-function': 'error', + 'no-unused-vars': 'off', + 'no-shadow': 'off', + 'no-console': [ + 'error', + { + allow: ['warn', 'error'], + }, + ], + camelcase: 'off', + + '@angular-eslint/component-selector': [ + 'error', + { type: 'element', prefix: appPrefix, style: 'kebab-case' }, + ], '@angular-eslint/directive-selector': [ 'error', { type: 'attribute', - prefix: 'app', + prefix: appPrefix, style: 'camelCase', }, ], - '@angular-eslint/component-selector': [ + '@angular-eslint/prefer-signals': 'error', + + '@typescript-eslint/array-type': ['off', { default: 'generic' }], + '@typescript-eslint/consistent-type-definitions': 'error', + '@typescript-eslint/dot-notation': 'off', + '@typescript-eslint/explicit-member-accessibility': [ + 'off', + { + accessibility: 'explicit', + }, + ], + '@typescript-eslint/member-ordering': [ 'error', { - type: 'element', - prefix: 'app', - style: 'kebab-case', + default: { + memberTypes: [ + 'signature', + 'field', + 'static-initialization', + 'constructor', + 'accessor', + ['get', 'set', 'method'], + ], + }, }, ], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'default', + format: ['camelCase'], + }, + { + selector: 'import', + format: ['camelCase', 'PascalCase'], + }, + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE'], + leadingUnderscore: 'allow', + }, + { + selector: 'parameter', + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'parameterProperty', + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'memberLike', + modifiers: ['private'], + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'property', + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'enumMember', + format: ['PascalCase', 'UPPER_CASE'], + leadingUnderscore: 'allow', + }, + { + selector: 'accessor', + format: ['camelCase', 'UPPER_CASE'], + }, + { + selector: 'typeLike', + format: ['PascalCase'], + }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + '@typescript-eslint/unbound-method': ['error', { ignoreStatic: true }], + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-inferrable-types': [ + 'error', + { ignoreParameters: true, ignoreProperties: true }, + ], }, }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5406edd..89f1842 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,12 @@ "name": "@geoengine/biois-ui", "version": "0.0.0", "dependencies": { + "@angular/cdk": "~21.1.3", "@angular/common": "^21.1.0", "@angular/compiler": "^21.1.0", "@angular/core": "^21.1.0", "@angular/forms": "^21.1.0", + "@angular/material": "~21.1.3", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", "rxjs": "~7.8.0", @@ -21,6 +23,9 @@ "@angular/build": "^21.1.3", "@angular/cli": "^21.1.3", "@angular/compiler-cli": "^21.1.0", + "@angular/material": "^21.1.3", + "@fontsource/material-icons": "^5.2.7", + "@fontsource/poppins": "^5.2.7", "@geoengine/biois": "file:../api-client/typescript", "@vitest/coverage-v8": "^4.0.18", "angular-eslint": "21.2.0", @@ -550,6 +555,22 @@ } } }, + "node_modules/@angular/cdk": { + "version": "21.1.3", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.3.tgz", + "integrity": "sha512-jMiEKCcZMIAnyx2jxrJHmw5c7JXAiN56ErZ4X+OuQ5yFvYRocRVEs25I0OMxntcXNdPTJQvpGwGlhWhS0yDorg==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "21.1.3", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.3.tgz", @@ -691,6 +712,24 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/material": { + "version": "21.1.3", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.1.3.tgz", + "integrity": "sha512-bVjtGSsQYOV6Z2cHCpdQVPVOdDxFKAprGV50BHRlPIIFl0X4hsMquFCMVTMExKP5ABKOzVt8Ae5faaszVcPh3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "21.1.3", + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/forms": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "21.1.3", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.1.3.tgz", @@ -1906,6 +1945,26 @@ } } }, + "node_modules/@fontsource/material-icons": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/material-icons/-/material-icons-5.2.7.tgz", + "integrity": "sha512-crPmK0L34lPGmS5GSGLasKpRGQzl95SxMsLM+QhBHPgR9uxSsyI5CUTb0cgoMpjtR+Bf1bC9QOe6pavoybbBwg==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/poppins": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz", + "integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@geoengine/biois": { "resolved": "../api-client/typescript", "link": true @@ -8811,7 +8870,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -8865,7 +8923,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" diff --git a/frontend/package.json b/frontend/package.json index 7d6d87b..b472eb5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,12 +25,15 @@ "private": true, "packageManager": "npm@10.9.2", "dependencies": { + "@angular/cdk": "~21.1.3", "@angular/common": "^21.1.0", "@angular/compiler": "^21.1.0", "@angular/core": "^21.1.0", "@angular/forms": "^21.1.0", + "@angular/material": "~21.1.3", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", + "@geoengine/biois": "file:../api-client/typescript", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -38,7 +41,9 @@ "@angular/build": "^21.1.3", "@angular/cli": "^21.1.3", "@angular/compiler-cli": "^21.1.0", - "@geoengine/biois": "file:../api-client/typescript", + "@angular/material": "^21.1.3", + "@fontsource/material-icons": "^5.2.7", + "@fontsource/poppins": "^5.2.7", "@vitest/coverage-v8": "^4.0.18", "angular-eslint": "21.2.0", "eslint": "^9.39.2", diff --git a/frontend/public/assets/GeoEngine_Structure_lightblue.svg b/frontend/public/assets/GeoEngine_Structure_lightblue.svg new file mode 100644 index 0000000..66125d3 --- /dev/null +++ b/frontend/public/assets/GeoEngine_Structure_lightblue.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/GeoEngine_Structure_lightgreen.svg b/frontend/public/assets/GeoEngine_Structure_lightgreen.svg new file mode 100644 index 0000000..38abc81 --- /dev/null +++ b/frontend/public/assets/GeoEngine_Structure_lightgreen.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/GeoEngine_Structure_midblue.svg b/frontend/public/assets/GeoEngine_Structure_midblue.svg new file mode 100644 index 0000000..a85b6e1 --- /dev/null +++ b/frontend/public/assets/GeoEngine_Structure_midblue.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/assets/geoengine-black.svg b/frontend/public/assets/geoengine-black.svg new file mode 100644 index 0000000..546c0d8 --- /dev/null +++ b/frontend/public/assets/geoengine-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/geoengine-white.svg b/frontend/public/assets/geoengine-white.svg new file mode 100644 index 0000000..8ad07ca --- /dev/null +++ b/frontend/public/assets/geoengine-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/hero-village.jpg b/frontend/public/assets/hero-village.jpg new file mode 100644 index 0000000..a80bf46 Binary files /dev/null and b/frontend/public/assets/hero-village.jpg differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 57614f9..b98f5c0 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index e75614a..4ec0520 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,8 +1,29 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; - import { routes } from './app.routes'; +import { MAT_CARD_CONFIG } from '@angular/material/card'; +import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; +import { PreventDefaultOnSubmitEventPlugin } from './util/prevent-default'; +import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser'; +import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } }, + { provide: MAT_CARD_CONFIG, useValue: { appearance: 'outlined' } }, + { + provide: EVENT_MANAGER_PLUGINS, + useClass: PreventDefaultOnSubmitEventPlugin, + multi: true, + }, + { + provide: DATE_PIPE_DEFAULT_OPTIONS, + useValue: { + /* timezone: 'CET' */ + dateFormat: "dd.MM.yyyy 'at' H:mm", + }, + }, + ], }; diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 426dccb..67e7bd4 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,351 +1 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for ( - item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { - title: 'Prompt and best practices for AI', - link: 'https://angular.dev/ai/develop-with-ai', - }, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { - title: 'Angular Language Service', - link: 'https://angular.dev/tools/language-service', - }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; - track item.title - ) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index dc39edb..8a9171e 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,3 +1,63 @@ import { Routes } from '@angular/router'; +import { LogInGuard } from './log-in.guard'; +import { inject } from '@angular/core'; +import { UserService } from './user.service'; -export const routes: Routes = []; +const appRoutes: Routes = [ + { + path: 'results', + title: 'Results', + loadComponent: () => import('./results/results.component').then((m) => m.ResultsComponent), + }, + { + path: 'results/:resultId', + title: 'Result Details', + loadComponent: () => import('./result/result.component').then((m) => m.DashboardComponent), + }, + { + path: 'create', + title: 'Create new', + loadComponent: () => + import('./create-new/create-new.component').then((m) => m.CreateNewComponent), + }, + { + path: 'signout', + title: 'BioIS – Sign Out', + redirectTo: (): string => { + const userService = inject(UserService); + userService.logout(); + return '/'; + }, + }, + { + path: '**', + redirectTo: 'results', + }, +]; + +export const routes: Routes = [ + { + path: '', + title: 'BioIS – Biodiversity Indicator Service', + loadComponent: () => + import('./landing-page/landing-page.component').then((m) => m.LandingPageComponent), + }, + { + path: 'app/signin', + title: 'BioIS – Sign In', + loadComponent: () => + import('./signin.component/signin.component').then((m) => m.SigninComponent), + }, + { + path: 'app', + title: 'BioIS – App', + children: appRoutes, + loadComponent: () => + import('./navigation/navigation.component').then((m) => m.NavigationComponent), + canActivate: [LogInGuard], + }, + { + path: '**', + redirectTo: '/', + }, +]; diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index 3365a78..75753d6 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -13,11 +13,4 @@ describe('App', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it('should render title', async () => { - const fixture = TestBed.createComponent(App); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, BioIS'); - }); }); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index d7f9631..df1afae 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,4 +1,6 @@ -import { Component, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; import { RouterOutlet } from '@angular/router'; @Component({ @@ -6,7 +8,24 @@ import { RouterOutlet } from '@angular/router'; imports: [RouterOutlet], templateUrl: './app.html', styleUrl: './app.scss', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class App { + private readonly matIconRegistry = inject(MatIconRegistry); + private readonly sanitizer = inject(DomSanitizer); + protected readonly title = signal('BioIS'); + + constructor() { + this.matIconRegistry.addSvgIconInNamespace( + 'geoengine', + 'logo', + this.sanitizer.bypassSecurityTrustResourceUrl('assets/geoengine-black.svg'), + ); + this.matIconRegistry.addSvgIconInNamespace( + 'geoengine', + 'logo-white', + this.sanitizer.bypassSecurityTrustResourceUrl('assets/geoengine-white.svg'), + ); + } } diff --git a/frontend/src/app/create-new/create-new.component.html b/frontend/src/app/create-new/create-new.component.html new file mode 100644 index 0000000..921ee34 --- /dev/null +++ b/frontend/src/app/create-new/create-new.component.html @@ -0,0 +1,80 @@ +
+
+ Inputs + +

+ @let coordinate = description.value()?.inputs?.['coordinate']; + {{ coordinate?.title }} + info +

+
+ + Longitude + + @for (error of form.inputs.coordinate.value.coordinates[0]().errors(); track error) { + {{ error.message }} + } + + + + Latitude + + @for (error of form.inputs.coordinate.value.coordinates[1]().errors(); track error) { + {{ error.message }} + } + + + @for (error of form.inputs.coordinate.value.coordinates().errors(); track error) { + {{ error.message }} + } +
+ +

+ @let year = description.value()?.inputs?.['year']; + {{ year?.title }} + info +

+ + Year + + @for (error of form.inputs.year().errors(); track error) { + {{ error.message }} + } + + +

+ @let month = description.value()?.inputs?.['month']; + {{ month?.title }} + info +

+ + Month + + @for (m of [1, 2, 3, 4, 5, 6]; track m) { + {{ m }} + } + + @for (error of form.inputs.month().errors(); track error) { + {{ error.message }} + } + +
+ +
+ Outputs + + @let ndvi = description.value()?.outputs?.['ndvi']; + {{ ndvi?.title }} + info + + @let kNdvi = description.value()?.outputs?.['kNdvi']; + {{ kNdvi?.title }} + info + + @for (error of form.outputs().errors(); track error) { + {{ error.message }} + } +
+ + +
diff --git a/frontend/src/app/create-new/create-new.component.scss b/frontend/src/app/create-new/create-new.component.scss new file mode 100644 index 0000000..431d590 --- /dev/null +++ b/frontend/src/app/create-new/create-new.component.scss @@ -0,0 +1,51 @@ +@use '@angular/material' as mat; + +form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 100%; + padding: 1rem; +} + +mat-form-field { + width: 100%; + max-width: 16rem; + + &:not(:last-child) { + margin-right: 1rem; + } +} + +button { + align-self: flex-start; +} + +fieldset { + border-color: var(--mat-sys-surface-container-lowest); + border-radius: var(--mat-sys-corner-medium); + padding: 1rem; + + legend { + font: var(--mat-sys-body-small); + color: var(--mat-sys-on-surface-variant); + } +} + +mat-icon { + margin-left: 0.5rem; + + @include mat.icon-overrides( + ( + color: var(--mat-sys-primary), + ) + ); +} + +p > mat-icon { + vertical-align: text-bottom; +} + +mat-checkbox ~ mat-icon { + vertical-align: middle; +} diff --git a/frontend/src/app/create-new/create-new.component.spec.ts b/frontend/src/app/create-new/create-new.component.spec.ts new file mode 100644 index 0000000..612626f --- /dev/null +++ b/frontend/src/app/create-new/create-new.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { Process, ProcessesApi } from '@geoengine/biois'; + +import { CreateNewComponent } from './create-new.component'; + +describe('CreateNewComponent', () => { + let component: CreateNewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + // mock ProcessesApi.process early so resource loaders in the component don't perform real network fetches + vi.spyOn(ProcessesApi.prototype, 'process').mockResolvedValue(ndviProcess()); + + await TestBed.configureTestingModule({ + imports: [CreateNewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateNewComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); + +function ndviProcess(): Process { + const process = new Process(); + process.id = 'ndvi'; + process.inputs = { + coordinate: { + title: 'Coordinate', + description: 'The coordinate to calculate the NDVI for', + schema: null, + }, + year: { + title: 'Year', + description: 'The year to calculate the NDVI for', + schema: null, + }, + month: { + title: 'Month', + description: 'The month to calculate the NDVI for', + schema: null, + }, + }; + process.outputs = { + ndvi: { + title: 'NDVI', + description: 'The calculated NDVI value', + schema: null, + }, + kNdvi: { + title: 'kNDVI', + description: 'The calculated kNDVI value', + schema: null, + }, + }; + return process; +} diff --git a/frontend/src/app/create-new/create-new.component.ts b/frontend/src/app/create-new/create-new.component.ts new file mode 100644 index 0000000..9d1220a --- /dev/null +++ b/frontend/src/app/create-new/create-new.component.ts @@ -0,0 +1,156 @@ +import { ChangeDetectionStrategy, Component, inject, resource, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + form, + FormField, + min, + max, + required, + applyEach, + validateTree, + FieldTree, + validate, +} from '@angular/forms/signals'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { + NDVIProcessInputs, + PointGeoJsonInputMediaType, + PointGeoJsonType, + ProcessesApi, + Response, +} from '@geoengine/biois'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { UserService } from '../user.service'; +import { Router } from '@angular/router'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-create-new', + imports: [ + CommonModule, + FormField, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIcon, + MatInputModule, + MatSelectModule, + MatTooltipModule, + ], + templateUrl: './create-new.component.html', + styleUrls: ['./create-new.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreateNewComponent { + readonly userService = inject(UserService); + readonly router = inject(Router); + + readonly formModel = signal({ + inputs: { + coordinate: { + value: { + type: PointGeoJsonType.Point, + coordinates: [0, 0], + }, + mediaType: PointGeoJsonInputMediaType.ApplicationGeojson, + }, + year: 2014, + month: 1, + } as NDVIProcessInputs, + outputs: { + ndvi: true, + kNdvi: true, + }, + }); + readonly form = form(this.formModel, (schema) => { + applyEach(schema, (field) => required(field, { message: 'This field is required.' })); + applyEach(schema.inputs, (field) => required(field, { message: 'This field is required.' })); + + applyEach(schema.inputs.coordinate.value.coordinates, (field) => { + required(field, { message: 'This field is required.' }); + }); + + validateTree(schema.inputs.coordinate.value.coordinates, (fields) => { + const coordinates = fields.value(); + if (coordinates?.length !== 2) { + return { + kind: 'invalidCoordinate', + message: 'Coordinates must be an array of two numbers.', + }; + } + + const [longitude, latitude] = coordinates; + + const arrayTree: FieldTree = fields.fieldTree; + + const [longitudeField, latitudeField] = [arrayTree[0], arrayTree[1]]; + + if (longitude < -180 || longitude > 180) { + return { + kind: 'invalidLongitude', + message: 'Longitude must be between -180 and 180.', + fieldTree: longitudeField, + }; + } + if (latitude < -90 || latitude > 90) { + return { + kind: 'invalidLatitude', + message: 'Latitude must be between -90 and 90.', + fieldTree: latitudeField, + }; + } + + return; + }); + + min(schema.inputs.year, 2014, { message: 'Year must be 2014 or later.' }); + max(schema.inputs.year, 2014, { message: 'Year must be 2014 or earlier.' }); + + min(schema.inputs.month, 1, { message: 'Month must be 1 or later.' }); + max(schema.inputs.month, 6, { message: 'Month must be 6 or earlier.' }); + + validate(schema.outputs, (outputs) => { + const { ndvi, kNdvi } = outputs.value(); + if (!ndvi && !kNdvi) { + return { + kind: 'noOutputSelected', + message: 'At least one output must be selected.', + }; + } + return; + }); + }); + + readonly description = resource({ + loader: () => { + const processApi = new ProcessesApi(this.userService.apiConfiguration()); + return processApi.process('ndvi'); + }, + }); + + async onSubmit(): Promise { + const processApi = new ProcessesApi(this.userService.apiConfiguration()); + + await processApi.executeNdvi({ + inputs: this.formModel().inputs, + outputs: outputForRequest(this.formModel().outputs), + response: Response.Document, + }); + + await this.router.navigate(['/app/results']); + } +} + +function outputForRequest(output: { ndvi: boolean; kNdvi: boolean }): { + ndvi?: Record; + kNdvi?: Record; +} { + return { + ndvi: output.ndvi ? {} : undefined, + kNdvi: output.kNdvi ? {} : undefined, + }; +} diff --git a/frontend/src/app/error.ts b/frontend/src/app/error.ts new file mode 100644 index 0000000..92ce7d6 --- /dev/null +++ b/frontend/src/app/error.ts @@ -0,0 +1,44 @@ +import { ApiException } from '@geoengine/biois'; + +/** + * Error based on [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) + */ +export class BackendError { + readonly type: string; + readonly status?: number; + readonly title?: string; + readonly detail?: string; + + constructor(type: string, status?: number, title?: string, detail?: string) { + this.type = type; + this.status = status; + this.title = title; + this.detail = detail; + } + + static fromError(error: unknown): BackendError { + if (error instanceof BackendError) { + return error; + } + + if (error instanceof ApiException) { + const body = error.body as { + type: string; + status?: number; + title?: string; + detail?: string; + }; // TODO: Use proper type checking here + return new BackendError(body.type, body.status, body.title, body.detail); + } + + if (error instanceof Error) { + return new BackendError(error.name, undefined, error.message, error.stack); + } + + if (typeof error === 'object' && error !== null) { + return new BackendError('Error', undefined, undefined, JSON.stringify(error)); + } + + return new BackendError('Error', undefined, undefined, String(error)); + } +} diff --git a/frontend/src/app/landing-page/landing-page.component.html b/frontend/src/app/landing-page/landing-page.component.html new file mode 100644 index 0000000..7b05b25 --- /dev/null +++ b/frontend/src/app/landing-page/landing-page.component.html @@ -0,0 +1,140 @@ + + + + + {{ title }} + + + +
+ +

{{ title }} — Biodiversity Indicator Service

+

+ Easy-to-use biodiversity indicators and analytics for corporate ESG reporting, with metrics and + workflows tailored to ESRS E4 requirements. +

+

+ + + Launch {{ title }} app + +

+
+ +
+
+

General information

+

+ {{ title }} helps companies and sustainability teams discover, process, and analyze + biodiversity datasets to produce robust, auditable indicators needed for ESG disclosures. Our + platform streamlines the generation of biodiversity metrics that support ESRS E4 reporting and + corporate sustainability workflows. We provide: +

+
    +
  • + REST APIs and data ingestion pipelines for dataset discovery, processing workflows, + automated indicator computation, and reporting-ready exports +
  • +
  • + Standards-based process interfaces ( OGC API - Processes) and output formats aligned with ESRS E4 expectations +
  • +
  • + Pre-built and customizable biodiversity indicators (e.g., species richness, habitat + intactness, NDVI-based trends) with provenance and parameter traceability +
  • +
  • Secure, auditable job execution with status reporting and result retrieval
  • +
+
+
+ +
+ +
+

Key features

+
+ + + + Cloud Processing + + + Easy-to-use cloud-processing for large-scale biodiversity indicators. Reproducible + workflows, provenance, and batch runs enable enterprise reporting and trend analysis. + + + + + + + APIs & Standards + + + REST APIs and + + OGC API - Processes + + interfaces for programmatic access, automated indicator generation, and exports compatible + with corporate ESG reporting needs. + + + + + + + Secure Jobs + + + Secure, auditable job execution with access controls, audit trails, and result exports + suitable for governance and disclosure workflows. + + +
+
+
+ +
+
+

Registration & Contact

+

+ This platform is in an early stage and available for pilots and early adopters focused on ESG + and biodiversity reporting. If you would like to test it, just get in contact with us to get + an account. If you would just like to now more, pleasure also feel free to reach out at + info@geoengine.de. +

+

+ We welcome collaboration to align indicators, validation approaches, and reporting outputs + with your sustainability needs. +

+
+
+ +
+ +
+

Launch the app

+

+ Open the BioIS application to explore biodiversity indicators, run ESRS E4–aligned analyses, + and configure reporting parameters for your ESG disclosures. The app lets sustainability teams + generate reporting-ready results, inspect provenance and inputs, and export indicator data for + governance and external reporting. +

+ + + Launch {{ title }} app + +
+
+ + diff --git a/frontend/src/app/landing-page/landing-page.component.scss b/frontend/src/app/landing-page/landing-page.component.scss new file mode 100644 index 0000000..3f1a850 --- /dev/null +++ b/frontend/src/app/landing-page/landing-page.component.scss @@ -0,0 +1,183 @@ +@use '@angular/material' as mat; + +$max-width: 1200px; + +:host { + display: block; + min-height: 100vh; + font-size: 16px; + --max-width: #{$max-width}; + + @include mat.toolbar-overrides( + ( + standard-height: 6rem, + mobile-height: 4rem, + ) + ); +} + +.top-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + + mat-toolbar-row { + max-width: var(--max-width); + margin: 0 auto; + justify-content: space-between; + padding: 0 2rem; + + mat-icon { + height: 2.5rem; + width: auto; + } + } +} + +.hero { + padding: 2rem 2rem; + position: relative; + + color: var(--mat-sys-surface-bright); + text-shadow: 0 2px 4px var(--mat-sys-on-surface); + + height: 30rem; + max-width: var(--max-width); + box-sizing: border-box; + margin: 8rem auto 0; + + display: flex; + flex-direction: column; + justify-content: end; + + img.background { + border-radius: 1rem; + @media (max-width: $max-width) { + border-radius: 0rem !important; + } + } + + a { + text-shadow: none; + } +} + +header, +section { + margin-bottom: 2rem; + padding: 2rem; +} + +header:has(.background), +section:has(.background) { + position: relative; + + img.background { + object-fit: cover; + z-index: -1; + } +} + +section { + .content { + max-width: var(--max-width); + margin: 0 auto; + padding: 2rem 0; + } + + a mat-icon { + vertical-align: top; + } + + mat-card-content a mat-icon { + vertical-align: bottom; + } +} + +section.features { + h2 { + margin-bottom: 2rem; + } + + mat-icon { + height: unset; + width: unset; + border-radius: unset; + margin-bottom: unset; + margin-top: 0.2rem; + } + + .features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; + } + + .feature-card { + padding: 1rem; + min-height: 15rem; + + mat-card-content { + margin-top: 1rem; + } + } + + @include mat.card-overrides( + ( + outlined-outline-width: 0, + ) + ); +} + +section .content.box { + background: var(--mat-sys-surface); + border-radius: var(--mat-sys-corner-medium); + padding: 2rem; +} + +footer { + max-width: var(--max-width); + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 3rem; + padding-bottom: 1rem; + margin: 0 auto; + + font: var(--mat-sys-body-medium); + color: var(--mat-sys-on-surface-variant); + + ul { + list-style: none; + display: flex; + margin: 0; + padding: 0; + gap: 0.5rem; + + li:not(:first-child)::before { + content: '•'; + padding-right: 0.5rem; + } + } +} + +@media (max-width: 900px) { + .features-grid { + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } +} + +@media (max-width: 600px) { + .features-grid { + grid-template-columns: 1fr; + } + + .hero { + margin-top: 6rem; + font-size: smaller; + } +} diff --git a/frontend/src/app/landing-page/landing-page.component.spec.ts b/frontend/src/app/landing-page/landing-page.component.spec.ts new file mode 100644 index 0000000..6d4929b --- /dev/null +++ b/frontend/src/app/landing-page/landing-page.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LandingPageComponent } from './landing-page.component'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { RouterModule } from '@angular/router'; + +describe('LandingPage', () => { + let component: LandingPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LandingPageComponent, MatIconTestingModule, RouterModule.forRoot([])], + }).compileComponents(); + + fixture = TestBed.createComponent(LandingPageComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render title', async () => { + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain( + 'BioIS — Biodiversity Indicator Service', + ); + }); +}); diff --git a/frontend/src/app/landing-page/landing-page.component.ts b/frontend/src/app/landing-page/landing-page.component.ts new file mode 100644 index 0000000..3f45d72 --- /dev/null +++ b/frontend/src/app/landing-page/landing-page.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { CommonModule, NgOptimizedImage } from '@angular/common'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; + +@Component({ + selector: 'app-landing-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + RouterModule, + CommonModule, + NgOptimizedImage, + MatToolbarModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatDividerModule, + ], + templateUrl: './landing-page.component.html', + styleUrls: ['./landing-page.component.scss'], +}) +export class LandingPageComponent { + readonly title = 'BioIS'; + readonly currentYear = new Date().getFullYear(); +} diff --git a/frontend/src/app/log-in.guard.ts b/frontend/src/app/log-in.guard.ts new file mode 100644 index 0000000..0cbea42 --- /dev/null +++ b/frontend/src/app/log-in.guard.ts @@ -0,0 +1,31 @@ +import { Injectable, inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + GuardResult, + MaybeAsync, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { UserService } from './user.service'; + +const SIGN_IN_PATH = '/app/signin'; + +@Injectable({ + providedIn: 'root', +}) +export class LogInGuard implements CanActivate { + private router = inject(Router); + private userService = inject(UserService); + + canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): MaybeAsync { + if (this.userService.isLoggedIn()) { + return true; + } + if (state.url === SIGN_IN_PATH) { + return true; + } + + return this.router.createUrlTree([SIGN_IN_PATH]); + } +} diff --git a/frontend/src/app/navigation/navigation.component.html b/frontend/src/app/navigation/navigation.component.html new file mode 100644 index 0000000..724124a --- /dev/null +++ b/frontend/src/app/navigation/navigation.component.html @@ -0,0 +1,41 @@ + + + + + + + + + + + @if (isHandset.value()) { + + } + @if (!drawer.opened) { + + } + + BioIS + + + + + + diff --git a/frontend/src/app/navigation/navigation.component.scss b/frontend/src/app/navigation/navigation.component.scss new file mode 100644 index 0000000..36281d4 --- /dev/null +++ b/frontend/src/app/navigation/navigation.component.scss @@ -0,0 +1,79 @@ +@use '@angular/material' as mat; + +$max-width: 1200px; + +:host { + --max-width: #{$max-width}; +} + +.background { + position: fixed; + top: 0; +} + +.sidenav-container { + height: 100%; + + max-width: var(--max-width); + margin: auto; +} + +mat-sidenav { + width: 200px; + + display: flex; + flex-direction: column; + + .spacer { + flex: 1; + } + + .menu-container { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + + mat-toolbar { + flex-shrink: 0; + } + + mat-nav-list { + flex: 1; + + display: flex; + flex-direction: column; + } + } + + @include mat.sidenav-overrides( + ( + container-divider-color: var(--mat-toolbar-container-background-color), + container-shape: 0, + ) + ); + + mat-icon { + vertical-align: bottom; + } +} + +mat-toolbar { + position: sticky; + top: 0; + z-index: 1; + + mat-toolbar-row { + max-width: var(--max-width); + margin: auto; + } + + .logo { + height: 1.8rem; + width: auto; + } + + .spacer { + flex: 1; + } +} diff --git a/frontend/src/app/navigation/navigation.component.spec.ts b/frontend/src/app/navigation/navigation.component.spec.ts new file mode 100644 index 0000000..a662540 --- /dev/null +++ b/frontend/src/app/navigation/navigation.component.spec.ts @@ -0,0 +1,34 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NavigationComponent } from './navigation.component'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { RouterModule } from '@angular/router'; + +describe('NavigationComponent', () => { + let component: NavigationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + MatButtonModule, + MatIconModule, + MatListModule, + MatSidenavModule, + MatToolbarModule, + RouterModule.forRoot([]), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NavigationComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/navigation/navigation.component.ts b/frontend/src/app/navigation/navigation.component.ts new file mode 100644 index 0000000..40c5967 --- /dev/null +++ b/frontend/src/app/navigation/navigation.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatListModule } from '@angular/material/list'; +import { MatIconModule } from '@angular/material/icon'; +import { map, shareReplay } from 'rxjs/operators'; +import { RouterModule, RouterOutlet } from '@angular/router'; +import { rxResource } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-navigation', + templateUrl: './navigation.component.html', + styleUrl: './navigation.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatToolbarModule, + MatButtonModule, + MatSidenavModule, + MatListModule, + MatIconModule, + RouterOutlet, + RouterModule, + ], +}) +export class NavigationComponent { + private breakpointObserver = inject(BreakpointObserver); + + readonly isHandset = rxResource({ + stream: () => + this.breakpointObserver.observe(Breakpoints.Handset).pipe( + map((result) => result.matches), + shareReplay(), + ), + defaultValue: false, + }); + + // isHandset$: Observable = this.breakpointObserver.observe(Breakpoints.Handset).pipe( + // map((result) => result.matches), + // shareReplay(), + // ); +} diff --git a/frontend/src/app/result/number-indicator.component.ts b/frontend/src/app/result/number-indicator.component.ts new file mode 100644 index 0000000..f3cc31f --- /dev/null +++ b/frontend/src/app/result/number-indicator.component.ts @@ -0,0 +1,105 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-number-indicator', + template: ` + +

{{ value() | number: '1.0-2' }}

+ `, + styles: ` + :host { + display: inline-block; + position: relative; + } + + p { + position: absolute; + top: calc(50% - 1.5rem); /* Adjust to vertically center the text */ + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.5rem; + font-weight: 500; /* Semi-bold */ + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatButtonModule, + MatCardModule, + MatGridListModule, + MatIconModule, + MatMenuModule, + MatProgressSpinner, + ], +}) +export class NumberIndicatorComponent { + readonly value = input.required(); + readonly min = input.required(); + readonly max = input.required(); + /** + * An array of color breakpoints that define the color for specific value ranges. + * Each breakpoint should specify a minimum and maximum value, as well as the corresponding color to be used for values that fall within that range. + * Must be in ascending order based on the minimum value to ensure correct color mapping. + */ + readonly colors = input.required>(); + readonly fallbackColor = input('#808080'); // Fallback gray + + /** Scales the value between 0 and 100 */ + readonly scaledValue = computed(() => { + const value = this.value(); + const min = this.min(); + const max = this.max(); + if (max === min) return 0; // Avoid division by zero, treat as zero + const percentage = (value - min) / (max - min); + return Math.max(0, Math.min(1, percentage)); // Clamp between 0 and 1 + }); + + readonly colorVariable = computed(() => { + const cssVariableName = `--mat-progress-spinner-active-indicator-color`; + + const value = this.value(); + const colors = this.colors(); + const fallbackColor = this.fallbackColor(); + + return `${cssVariableName}: ${valueToColor(value, colors, fallbackColor)};`; + }); +} + +/** + * Maps a value to a color. + * + * @param value a numeric value to be mapped to a color + * @param colors an array of color breakpoints that define the color for specific value ranges + * @returns a string representing the color corresponding to the given value based on the provided color breakpoints. + * If the value does not fall within any of the specified ranges, a fallback color is returned. + */ +function valueToColor( + value: number, + colors: Array, + fallbackColor: string, +): string { + for (const range of colors) { + if (value >= range.min && value <= range.max) { + return range.color; + } + } + + return fallbackColor; +} + +export interface ColorBreakpoint { + min: number; + max: number; + color: string; +} diff --git a/frontend/src/app/result/result.component.html b/frontend/src/app/result/result.component.html new file mode 100644 index 0000000..3adfa37 --- /dev/null +++ b/frontend/src/app/result/result.component.html @@ -0,0 +1,66 @@ +
+

Result {{ processId() }}

+
+ + + + Inputs + + + + + + This will display the inputs of the selected process. + + + + + + + Results + + + + + + + + @if (result.value().ndvi; as ndvi) { +

NDVI:

+ + } + @if (result.value().kNdvi; as kNdvi) { +

kNDVI:

+ + } +
+
+
+
diff --git a/frontend/src/app/result/result.component.scss b/frontend/src/app/result/result.component.scss new file mode 100644 index 0000000..f235377 --- /dev/null +++ b/frontend/src/app/result/result.component.scss @@ -0,0 +1,47 @@ +@use '@angular/material' as mat; + +:host { + --min-width-grid-cell: 300px; +} + +h1 { + margin: 1.5rem 1rem; +} + +.grid-container { + margin: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(var(--min-width-grid-cell), 1fr)); + gap: 1rem; +} + +.more-button { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +mat-card-content { + margin-top: 1rem; + text-align: center; + + code { + display: block; + text-align: left; + } + + .indicator { + display: block; + position: relative; + text-align: center; + + mat-progress-spinner { + margin: auto; + } + + .indicator-number { + margin-top: -3.5rem; + margin-bottom: 3.5rem; + } + } +} diff --git a/frontend/src/app/result/result.component.spec.ts b/frontend/src/app/result/result.component.spec.ts new file mode 100644 index 0000000..e18de95 --- /dev/null +++ b/frontend/src/app/result/result.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DashboardComponent } from './result.component'; +import { RouterModule } from '@angular/router'; + +describe('DashboardComponent', () => { + let component: DashboardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterModule.forRoot([])], + }).compileComponents(); + fixture = TestBed.createComponent(DashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/result/result.component.ts b/frontend/src/app/result/result.component.ts new file mode 100644 index 0000000..a22dbf3 --- /dev/null +++ b/frontend/src/app/result/result.component.ts @@ -0,0 +1,99 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + resource, + ResourceRef, + Signal, +} from '@angular/core'; +import { Breakpoints, BreakpointObserver } from '@angular/cdk/layout'; +import { map } from 'rxjs/operators'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { UserService } from '../user.service'; +import { NDVIProcessOutputs, ProcessesApi } from '@geoengine/biois'; +import { CommonModule } from '@angular/common'; +import { ColorBreakpoint, NumberIndicatorComponent } from './number-indicator.component'; + +@Component({ + selector: 'app-result', + templateUrl: './result.component.html', + styleUrl: './result.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatButtonModule, + MatCardModule, + MatGridListModule, + MatIconModule, + MatMenuModule, + NumberIndicatorComponent, + ], +}) +export class DashboardComponent { + private readonly breakpointObserver = inject(BreakpointObserver); + private readonly activatedRoute = inject(ActivatedRoute); + private readonly userService = inject(UserService); + + readonly processId: Signal; + + readonly result: ResourceRef = resource({ + params: () => ({ + processId: this.processId(), + }), + defaultValue: {}, + loader: async ({ params }) => { + const api = new ProcessesApi(this.userService.apiConfiguration()); + if (!params.processId) return {}; + + const result = await api.results(params.processId); + + if (result instanceof Blob) { + throw new Error('Expected NDVIProcessOutputs but received HttpFile'); + } + + return result; + }, + }); + + readonly colspan = toSignal( + this.breakpointObserver + .observe(Breakpoints.Handset) + .pipe(map(({ matches }) => (matches ? 2 : 1))), + ); + + readonly ndviColorMap: Array = [ + { min: -1, max: 0, color: '#8B4513' }, // Barren ground/cities - brown + { min: 0, max: 0.1, color: '#A0522D' }, // Very little vegetation - saddle brown + { min: 0.1, max: 0.3, color: '#DAA520' }, // Sparse vegetation - goldenrod + { min: 0.3, max: 0.6, color: '#9ACD32' }, // Moderate vegetation - yellow-green + { min: 0.6, max: 0.9, color: '#32CD32' }, // Healthy crops - lime green + { min: 0.9, max: 1, color: '#008000' }, // Dense vegetation - dark green + ]; + + constructor() { + this.processId = toSignal( + this.activatedRoute.params.pipe( + map((params) => ('resultId' in params ? (params['resultId'] as string) : undefined)), + ), + ); + } + + async download(): Promise { + const processId = this.processId(); + if (!processId) return; + + const api = new ProcessesApi(this.userService.apiConfiguration()); + const result = await api.results(processId); + + const link = document.createElement('a'); + link.href = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(result)); + link.download = `result-${processId}.json`; + link.click(); + } +} diff --git a/frontend/src/app/results/jobs-datasource.ts b/frontend/src/app/results/jobs-datasource.ts new file mode 100644 index 0000000..05d257f --- /dev/null +++ b/frontend/src/app/results/jobs-datasource.ts @@ -0,0 +1,73 @@ +import { DataSource } from '@angular/cdk/collections'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { Observable, startWith, switchMap } from 'rxjs'; +import { Configuration, ProcessesApi, StatusInfo } from '@geoengine/biois'; + +/** + * Data source for the Table view. This class should + * encapsulate all logic for fetching and manipulating the displayed data + * (including sorting, pagination, and filtering). + */ +export class JobsDataSource extends DataSource { + paginator?: MatPaginator; + // sort: MatSort | undefined; + + protected readonly api: ProcessesApi; + + constructor(config: Configuration) { + super(); + this.api = new ProcessesApi(config); + } + + /** + * Connect this data source to the table. The table will only update when + * the returned stream emits new items. + * @returns A stream of the items to be rendered. + */ + connect(): Observable { + if (!this.paginator) { + throw Error('Please set the paginator on the data source before connecting.'); + } + + return this.paginator.page.pipe( + startWith(currentPageAsEvent(this.paginator)), + switchMap((pageEvent) => + this.queryDataPage(pageEvent.pageIndex * pageEvent.pageSize, pageEvent.pageSize), + ), + ); + } + + /** + * Called when the table is being destroyed. Use this function, to clean up + * any open connections or free any held resources that were set up during connect. + */ + disconnect(): void { + // no-op + } + + protected async queryDataPage(offset: number, limit: number): Promise { + const jobs = (await this.api.jobs(limit, offset)).jobs; + + if (this.paginator && limit <= jobs.length) { + this.paginator.length = Math.max( + this.paginator.length, + offset + jobs.length + /* no page is shown if we won't expect another item */ 1, + ); + } + + return jobs; + } +} + +/** + * Converts the current page of the paginator into a PageEvent, which can be emitted to trigger a reload of the current page. + * @param paginator + * @returns + */ +export function currentPageAsEvent(paginator: MatPaginator): PageEvent { + const pageEvent = new PageEvent(); + pageEvent.pageIndex = paginator.pageIndex; + pageEvent.pageSize = paginator.pageSize; + pageEvent.length = paginator.length; + return pageEvent; +} diff --git a/frontend/src/app/results/results.component.html b/frontend/src/app/results/results.component.html new file mode 100644 index 0000000..0601de9 --- /dev/null +++ b/frontend/src/app/results/results.component.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Results + + + Show Results + + + Status + @switch (row.status) { + @case (StatusCode.Accepted) { + task + } + @case (StatusCode.Running) { + + } + @case (StatusCode.Successful) { + check_circle + } + @case (StatusCode.Failed) { + error + } + @case (StatusCode.Dismissed) { + cancel + } + } + Updated + {{ row.updated | date }} +
No data to show
+ +
+ + + +
diff --git a/frontend/src/app/results/results.component.scss b/frontend/src/app/results/results.component.scss new file mode 100644 index 0000000..bf4f48c --- /dev/null +++ b/frontend/src/app/results/results.component.scss @@ -0,0 +1,49 @@ +.full-width-table { + width: 100%; +} + +th.center, +td.center { + text-align: center; +} + +.no-data-row { + text-align: center; + font-style: italic; + color: var(--mat-sys-on-surface-variant); +} + +cdk-virtual-scroll-viewport { + height: 100vh; +} + +mat-icon { + color: var(--mat-sys-secondary); + + &.error { + color: var(--mat-sys-error); + } + + &.success { + color: var(--mat-sys-primary); + } + + &.info { + color: var(--mat-sys-primary); + margin-left: 0.25em; + } +} + +.bottom { + display: flex; + align-items: center; + + button { + flex: 0; + margin-left: 0.25rem; + } + + mat-paginator { + flex: 1; + } +} diff --git a/frontend/src/app/results/results.component.spec.ts b/frontend/src/app/results/results.component.spec.ts new file mode 100644 index 0000000..cd88eab --- /dev/null +++ b/frontend/src/app/results/results.component.spec.ts @@ -0,0 +1,18 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResultsComponent } from './results.component'; + +describe('ResultsComponent', () => { + let component: ResultsComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(ResultsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/results/results.component.ts b/frontend/src/app/results/results.component.ts new file mode 100644 index 0000000..4dd712a --- /dev/null +++ b/frontend/src/app/results/results.component.ts @@ -0,0 +1,85 @@ +import { + afterRenderEffect, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + TrackByFunction, + viewChild, +} from '@angular/core'; +import { MatTableModule, MatTable } from '@angular/material/table'; +import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; +import { MatSortModule, MatSort } from '@angular/material/sort'; +import { currentPageAsEvent, JobsDataSource as JobsDataSource } from './jobs-datasource'; +import { UserService } from '../user.service'; +import { StatusCode, StatusInfo } from '@geoengine/biois'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { DatePipe } from '@angular/common'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { MatAnchor, MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'app-results', + templateUrl: './results.component.html', + styleUrl: './results.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DatePipe, + MatButtonModule, + MatIconModule, + MatPaginatorModule, + MatProgressBarModule, + MatSortModule, + MatTableModule, + MatTooltipModule, + RouterLink, + ScrollingModule, + MatAnchor, + ], +}) +export class ResultsComponent { + readonly userService = inject(UserService); + readonly paginator = viewChild.required(MatPaginator); + readonly sort = viewChild.required(MatSort); + readonly table = viewChild.required(MatTable); + readonly changeDetector = inject(ChangeDetectorRef); + + readonly StatusCode = StatusCode; + + readonly dataSource = new JobsDataSource(this.userService.apiConfiguration()); + + /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ + readonly displayedColumns = ['updated', 'jobID', 'processID', 'status', 'message']; + + readonly pageSize = 20; // TODO: get from server settings + + constructor() { + afterRenderEffect(() => { + const table = this.table(); + + if (table.dataSource) return; + + this.dataSource.paginator = this.paginator(); + table.dataSource = this.dataSource; + }); + } + + readonly trackByFn: TrackByFunction = (_index, item) => item.jobID; + + updatedTooltip(start: string | null, end: string | null): string { + if (!start) { + return ''; + } + if (!end) { + return `Started ${start}`; + } + return `Started ${start}; Finished: ${end}`; + } + + refresh(): void { + this.paginator().page.next(currentPageAsEvent(this.paginator())); + } +} diff --git a/frontend/src/app/signin.component/signin.component.html b/frontend/src/app/signin.component/signin.component.html new file mode 100644 index 0000000..5452f47 --- /dev/null +++ b/frontend/src/app/signin.component/signin.component.html @@ -0,0 +1,69 @@ + + + + @switch (state()) { + @case (SigninState.InProgress) { + hourglass_top + } + @case (SigninState.InvalidState) { + report_problem + } + @case (SigninState.Error) { + error + } + @case (SigninState.LoggedIn) { + check_circle + } + } + + + @switch (state()) { + @case (SigninState.InProgress) { + Signing in… + } + @case (SigninState.InvalidState) { + Unexpected response + } + @case (SigninState.Error) { + Error signing in + } + @case (SigninState.LoggedIn) { + Signed in + } + } + + + + @switch (state()) { + @case (SigninState.InProgress) { + + } + @case (SigninState.InvalidState) { +

Invalid response. Please try again.

+ } + @case (SigninState.Error) { + @if (error()?.status; as status) { +

Status: {{ status }}

+ } + @if (error()?.title; as title) { +

Title: {{ title }}

+ } + @if (error()?.detail; as detail) { +

{{ detail }}

+ } + } + @case (SigninState.LoggedIn) { +

+ Successfully logged in! +

+ @if (userSession()?.user; as user) { + Welcome, {{ user?.realName }} <{{ user?.email }}>! +
+ Id: {{ user.id }} + } +

+ + } + } +
+
diff --git a/frontend/src/app/signin.component/signin.component.scss b/frontend/src/app/signin.component/signin.component.scss new file mode 100644 index 0000000..74762f4 --- /dev/null +++ b/frontend/src/app/signin.component/signin.component.scss @@ -0,0 +1,19 @@ +:host { + height: 100vh; + display: grid; + place-items: center; +} + +// mat-icon.mat-card-avatar { +// width: 40px; +// height: 40px; +// font-size: 40px; +// } + +mat-card { + max-width: 50rem; + mat-card-content { + display: grid; + place-items: center; + } +} diff --git a/frontend/src/app/signin.component/signin.component.spec.ts b/frontend/src/app/signin.component/signin.component.spec.ts new file mode 100644 index 0000000..f60da62 --- /dev/null +++ b/frontend/src/app/signin.component/signin.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SigninComponent } from './signin.component'; +import { RouterModule } from '@angular/router'; + +describe('SigninComponent', () => { + let component: SigninComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterModule.forRoot([])], + }).compileComponents(); + + fixture = TestBed.createComponent(SigninComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/signin.component/signin.component.ts b/frontend/src/app/signin.component/signin.component.ts new file mode 100644 index 0000000..5925716 --- /dev/null +++ b/frontend/src/app/signin.component/signin.component.ts @@ -0,0 +1,116 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { UserService } from '../user.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; +import { BackendError } from '../error'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatButtonModule } from '@angular/material/button'; + +enum SigninState { + InProgress, + InvalidState, + Error, + LoggedIn, +} + +@Component({ + selector: 'app-signin.component', + imports: [MatCardModule, MatIconModule, MatProgressSpinnerModule, MatButtonModule], + templateUrl: './signin.component.html', + styleUrl: './signin.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SigninComponent implements OnInit { + private readonly userService = inject(UserService); + private readonly activatedRoute = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly userSession = this.userService.userSession; + protected readonly error = signal(undefined); + protected readonly invalidResponse = signal(false); + + readonly SigninState = SigninState; + readonly state = computed(() => { + const user = this.userService.userSession(); + const error = this.error(); + const invalidResponse = this.invalidResponse(); + + if (user) { + return SigninState.LoggedIn; + } + + if (error) { + return SigninState.Error; + } + + if (invalidResponse) { + return SigninState.InvalidState; + } + + return SigninState.InProgress; + }); + + ngOnInit(): void { + const _ = this.onInit(); + } + + protected async onInit(): Promise { + const queryParams: Partial = await firstValueFrom( + this.activatedRoute.queryParams, + ); + + if (this.userSession()) { + return; + } + + if (Object.keys(queryParams).length === 0) { + try { + await this.userService.oidcRedirect(); + } catch (error) { + this.error.set(BackendError.fromError(error)); + } + return; + } + + if (!queryParams.code || !queryParams.session_state || !queryParams.state) { + this.invalidResponse.set(true); + return; + } + + // TODO: remove query parameters from URL after processing to avoid confusion on page reload, e.g. using `location.replaceState` or `history.replaceState` + + try { + await this.userService.login({ + code: queryParams.code, + sessionState: queryParams.session_state, + state: queryParams.state, + }); + await this.navigateToApp(); + } catch (error) { + this.error.set(BackendError.fromError(error)); + } + } + + async navigateToApp(): Promise { + await this.router.navigate(['/app']); + } +} + +/** + * The expected query parameters from the OIDC code flow after a successful authentication. + */ +interface AuthResponseQueryParams { + state: string; + code: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + session_state: string; +} diff --git a/frontend/src/app/user.service.spec.ts b/frontend/src/app/user.service.spec.ts new file mode 100644 index 0000000..5d33709 --- /dev/null +++ b/frontend/src/app/user.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; + +import { UserService, __TEST__ } from './user.service'; + +describe('UserService', () => { + let service: UserService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(UserService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); + +describe('UserServiceHelpers', () => { + it('should compare dates', () => { + expect( + __TEST__.sessionIsValid( + { + created: '', + id: '', + roles: [], + user: { + id: '', + }, + validUntil: '2026-02-18T17:20:42.536Z', + }, + Date.parse('2026-02-18T16:20:42.536Z'), + ), + ).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/user.service.ts b/frontend/src/app/user.service.ts new file mode 100644 index 0000000..4ecd74a --- /dev/null +++ b/frontend/src/app/user.service.ts @@ -0,0 +1,123 @@ +import { effect, Injectable, Signal, signal } from '@angular/core'; +import { + createConfiguration, + ServerConfiguration, + UserSession, + UserApi, + Configuration, + AuthMethodsConfiguration, +} from '@geoengine/biois'; + +const USER_SESSION_KEY = 'userSession'; + +@Injectable({ + providedIn: 'root', +}) +export class UserService { + protected readonly user = signal(undefined); + + constructor() { + effect(() => { + const user = this.user(); + if (user) { + sessionStorage.setItem(USER_SESSION_KEY, JSON.stringify(user)); + } else { + sessionStorage.removeItem(USER_SESSION_KEY); + } + }); + + const storedUser = sessionStorage.getItem(USER_SESSION_KEY); + if (storedUser) { + try { + const userSession = JSON.parse(storedUser) as UserSession; /* TODO: proper type checking */ + if (sessionIsValid(userSession)) this.user.set(userSession); + } catch (error) { + console.error('Failed to parse stored user session:', error); + } + } + } + + isLoggedIn(): boolean { + return this.user() !== undefined; + } + + get userSession(): Signal { + return this.user; + } + + // TODO: logout after session expires, refresh session, … + async login(auth: { state: string; code: string; sessionState: string }): Promise { + const userApi = new UserApi(configuration()); + const redirectUri = location.origin + location.pathname; + const user = await userApi.authHandler(redirectUri, auth); + + this.user.set(user); + } + + logout(): void { + // TODO: this is currently only used to clear the session on the frontend, + // but we should also invalidate the session on the backend + this.user.set(undefined); + } + + async oidcRedirect(): Promise { + // TODO: try out `angular-auth-oidc-client` or `oidc-client-ts` instead of implementing OIDC ourselves + + const userApi = new UserApi(configuration()); + const redirectUri = oidcRedirectUri(); + const oidcUrl = await userApi.authRequestUrlHandler(redirectUri); + + window.location.href = oidcUrl; + } + + apiConfiguration(): Configuration { + const authMethods = { + accessToken: 'Bearer ' + this.user()?.id /* TODO: handle missing/expired token */, + }; + const config = createConfiguration({ + baseServer: new ServerConfiguration('http://localhost:4040', {}), + // authMethods: authMethods as AuthMethodsConfiguration, + authMethods: { + default: { + getName: () => 'default', + applySecurityAuthentication: (context) => { + context.setHeaderParam('Authorization', authMethods.accessToken); + // TODO: separate this? + context.setHeaderParam('Prefer', 'respond-async'); + }, + }, + } as AuthMethodsConfiguration, + }); + // if (this.user()) { + // config.baseServer = this.user().accessToken; + // } + return config; + } +} + +/** + * Generates the redirect URI for the OIDC code flow, which is typically the current URL of the frontend application. This URI is used both to initiate the OIDC flow and as the redirect URI registered with the identity provider. + * @returns The redirect URI as a string. + */ +function oidcRedirectUri(): string { + return location.origin + location.pathname; +} + +function configuration(/*options: { authMethods?: OAuth2Configuration } = {}*/): Configuration { + return createConfiguration({ + baseServer: new ServerConfiguration('http://localhost:4040', {}), + // ...options, + }); +} + +function sessionIsValid(userSession: UserSession, now: number = Date.now()): boolean { + // const validUntil = Temporal.Instant.from(userSession.validUntil); + // const now = Temporal.Now.instant(); + const validUntil = Date.parse(userSession.validUntil); + return now < validUntil; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const __TEST__ = { + sessionIsValid, +}; diff --git a/frontend/src/app/util/prevent-default.ts b/frontend/src/app/util/prevent-default.ts new file mode 100644 index 0000000..dfbf40b --- /dev/null +++ b/frontend/src/app/util/prevent-default.ts @@ -0,0 +1,28 @@ +import { EventManagerPlugin } from '@angular/platform-browser'; + +/** + * An `EventManagerPlugin` that prevents the default action of `submit` events. + * This is necessary to prevent the page from reloading when a form is submitted. + */ +export class PreventDefaultOnSubmitEventPlugin extends EventManagerPlugin { + supports(eventName: string): boolean { + return eventName == 'submit'; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + addEventListener(element: HTMLElement, eventName: string, handler: Function) { + const [actualEvent] = eventName.split('.'); + + const callback = (event: Event): void => { + event.preventDefault(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + handler(event); + }; + + element.addEventListener(actualEvent, callback); + + return (): void => { + element.removeEventListener(actualEvent, callback); + }; + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html index 501eca2..783a86d 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -6,6 +6,7 @@ + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 190f341..381620b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,4 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; +import '@fontsource/material-icons'; +import '@fontsource/poppins'; + bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 90d4ee0..7f10bd4 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1 +1,70 @@ +// Include theming for Angular Material with `mat.theme()`. +// This Sass mixin will define CSS variables that are used for styling Angular Material +// components according to the Material 3 design spec. +// Learn more about theming and how to use it for your application's +// custom components at https://material.angular.dev/guide/theming +@use '@angular/material' as mat; +@use '../theme-colors' as themeColors; + +html { + height: 100%; + @include mat.theme( + ( + color: ( + primary: themeColors.$primary-palette, + tertiary: themeColors.$primary-palette, + ), + typography: Poppins, + density: 0, + ) + ); + + @include mat.toolbar-overrides( + ( + container-background-color: var(--mat-sys-on-surface), + container-text-color: var(--mat-sys-surface), + ) + ); +} + +body { + // Default the application to a light color theme. This can be changed to + // `dark` to enable the dark color theme, or to `light dark` to defer to the + // user's system settings. + color-scheme: light; + // color-scheme: light dark; // TODO: Test dark theme + + // Set a default background, font and text colors for the application using + // Angular Material's system-level CSS variables. Learn more about these + // variables at https://material.angular.dev/guide/system-variables + background-color: var(--mat-sys-surface); + color: var(--mat-sys-on-surface); + font: var(--mat-sys-body-medium); + a { + color: var(--mat-sys-primary); + &:hover { + color: var(--mat-sys-inverse-primary); + } + } + + // Reset the user agent margin. + margin: 0; + height: 100%; +} /* You can add global styles to this file, and also import other style files */ + +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -moz-font-feature-settings: 'liga'; + -moz-osx-font-smoothing: grayscale; +} diff --git a/justfile b/justfile index 1901c0b..e91c5a7 100644 --- a/justfile +++ b/justfile @@ -25,6 +25,9 @@ install-llvm-cov: [working-directory('frontend')] install-frontend-deps: @-clear + rm -rf node_modules/@geoengine/biois \ + .angular/cache + npm link ../api-client/typescript npm ci [group('frontend')] @@ -180,6 +183,11 @@ test-frontend: ### RUN ### +# Run backend and frontend at the same time. Usage: `just run`. +[group('run')] +[parallel] +run: run-backend run-frontend + # Run the backend. Usage: `just run-backend --release`. [group('backend')] [group('run')] diff --git a/openapi.json b/openapi.json index eb4f0c0..7f3494a 100644 --- a/openapi.json +++ b/openapi.json @@ -296,6 +296,34 @@ "summary": "Retrieve the list of jobs", "description": "For more information, see [Section 11](https://docs.ogc.org/is/18-062/18-062.html#sc_job_list).", "operationId": "jobs", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Amount of items to return", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + { + "name": "offset", + "in": "query", + "description": "Offset into the items list", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + ], "responses": { "200": { "description": "A list of jobs for this process.", @@ -470,12 +498,24 @@ } } }, - "/auth": { + "/auth/accessTokenLogin": { "post": { "tags": [ "User" ], "operationId": "auth_handler", + "parameters": [ + { + "name": "redirectUri", + "in": "query", + "description": "The URI to which the identity provider should redirect after successful authentication.", + "required": true, + "schema": { + "type": "string", + "format": "uri" + } + } + ], "requestBody": { "content": { "application/json": { @@ -514,6 +554,54 @@ } } }, + "/auth/authenticationRequestUrl": { + "get": { + "tags": [ + "User" + ], + "summary": "Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider's login page.", + "operationId": "auth_request_url_handler", + "parameters": [ + { + "name": "redirectUri", + "in": "query", + "description": "The URI to which the identity provider should redirect after successful authentication.", + "required": true, + "schema": { + "type": "string", + "format": "uri" + } + } + ], + "responses": { + "200": { + "description": "A URL for initiating the OIDC code flow.", + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "uri" + } + } + } + }, + "500": { + "description": "A server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Exception" + }, + "example": { + "status": 500, + "type": "https://httpwg.org/specs/rfc7231.html#status.500" + } + } + } + } + } + } + }, "/processes/ndvi/execution": { "post": { "tags": [ @@ -524,7 +612,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NDVIProcessInputs" + "$ref": "#/components/schemas/NDVIProcessParams" } } }, @@ -1038,7 +1126,7 @@ ], "format": "double" }, - "k_ndvi": { + "kNdvi": { "type": [ "number", "null" @@ -1047,6 +1135,30 @@ } } }, + "NDVIProcessParams": { + "type": "object", + "description": "Process execution", + "required": [ + "inputs" + ], + "properties": { + "inputs": { + "$ref": "#/components/schemas/NDVIProcessInputs" + }, + "outputs": { + "type": "object", + "additionalProperties": { + "default": null + }, + "propertyNames": { + "type": "string" + } + }, + "response": { + "$ref": "#/components/schemas/Response" + } + } + }, "Output": { "type": "object", "description": "Process execution output", @@ -1232,10 +1344,19 @@ ] }, "Results": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/InlineOrRefData" - } + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/InlineOrRefData" + } + }, + { + "type": "string", + "format": "binary" + } + ], + "title": "ExecuteResults" }, "Schema": { "oneOf": [ diff --git a/test-client/call.http b/test-client/call.http index 6085efa..2f1bf9a 100644 --- a/test-client/call.http +++ b/test-client/call.http @@ -29,6 +29,57 @@ Content-Type: application/json ### +POST http://localhost:4040/processes/ndvi/execution +Authorization: Bearer {{anonymousSession.response.body.$.id}} +Content-Type: application/json + +{ + "inputs": { + "coordinate": { + "value": { + "type": "Point", + "coordinates": [9.77, 49.54] + }, + "mediaType": "application/geo+json" + }, + "year": 2014, + "month": 3 + }, + "outputs": { + "ndvi": {} + }, + "response": "document" +} + +### + +POST http://localhost:4040/processes/ndvi/execution +Authorization: Bearer {{anonymousSession.response.body.$.id}} +Content-Type: application/json + +{ + "inputs": { + "coordinate": { + "value": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + }, + "mediaType": "application/geo+json" + }, + "year": 2014, + "month": 1 + }, + "outputs": { + "kNdvi": {} + }, + "response": "document" +} + +### + # @name process POST http://localhost:4040/processes/ndvi/execution Authorization: Bearer {{anonymousSession.response.body.$.id}}