From ff29e94e981a80a8665d82f2190f34f79087531e Mon Sep 17 00:00:00 2001 From: Peter Somogyvari Date: Wed, 13 Sep 2023 16:39:42 -0700 Subject: [PATCH] feat(cmd-api-server): add GetOpenApiSpecV1Endpoint (HTTP GET) The new endpoint can serve up the OpenAPI specification file of the API server via a get request without any parameters needed. Under the hood it uses the re-usable common base endpoint class that we've recently added to the core package. The test case at this file path demonstrates how to use it via the API client: `packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts` Signed-off-by: Peter Somogyvari --- packages/cactus-cmd-api-server/package.json | 1 + .../src/main/json/openapi.json | 29 +++++ .../generated/openapi/kotlin-client/README.md | 1 + .../openapitools/client/apis/DefaultApi.kt | 68 ++++++++++ .../openapi/services/default_service.proto | 6 + .../src/main/typescript/api-server.ts | 71 +++++++---- .../generated/openapi/typescript-axios/api.ts | 56 +++++++++ .../protoc-gen-ts/services/default_service.ts | 75 +++++++++++ .../services/default_service_grpc_pb.d.ts | 17 +++ .../services/default_service_pb.d.ts | 20 +++ .../typescript/openapi/get-open-api-spec.ts | 48 +++++++ .../get-open-api-spec-v1-endpoint.ts | 37 ++++++ .../grpc/grpc-server-api-server.ts | 13 ++ .../get-open-api-spec-v1-endpoint.test.ts | 119 ++++++++++++++++++ yarn.lock | 1 + 15 files changed, 540 insertions(+), 22 deletions(-) create mode 100644 packages/cactus-cmd-api-server/src/main/typescript/openapi/get-open-api-spec.ts create mode 100644 packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts create mode 100644 packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts diff --git a/packages/cactus-cmd-api-server/package.json b/packages/cactus-cmd-api-server/package.json index 7f86980245a..9f65efcba6b 100644 --- a/packages/cactus-cmd-api-server/package.json +++ b/packages/cactus-cmd-api-server/package.json @@ -86,6 +86,7 @@ "prom-client": "13.2.0", "run-time-error": "1.4.0", "rxjs": "7.8.1", + "safe-stable-stringify": "2.4.3", "semver": "7.5.2", "socket.io": "4.5.4", "socket.io-client": "4.5.4", diff --git a/packages/cactus-cmd-api-server/src/main/json/openapi.json b/packages/cactus-cmd-api-server/src/main/json/openapi.json index 53aea86c24d..a292548f4cd 100644 --- a/packages/cactus-cmd-api-server/src/main/json/openapi.json +++ b/packages/cactus-cmd-api-server/src/main/json/openapi.json @@ -72,6 +72,10 @@ "PrometheusExporterMetricsResponse": { "type": "string", "nullable": false + }, + "GetOpenApiSpecV1EndpointResponse": { + "type": "string", + "nullable": false } } }, @@ -126,6 +130,31 @@ } } } + }, + "/api/v1/api-server/get-open-api-spec": { + "get": { + "description": "Returns the openapi.json document of specific plugin.", + "x-hyperledger-cactus": { + "http": { + "verbLowerCase": "get", + "path": "/api/v1/api-server/get-open-api-spec" + } + }, + "operationId": "getOpenApiSpecV1", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOpenApiSpecV1EndpointResponse" + } + } + } + } + } + } } } } diff --git a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md index 139873331a5..5889e95417b 100644 --- a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md +++ b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/README.md @@ -45,6 +45,7 @@ All URIs are relative to *http://localhost* Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- *DefaultApi* | [**getHealthCheckV1**](docs/DefaultApi.md#gethealthcheckv1) | **GET** /api/v1/api-server/healthcheck | Can be used to verify liveness of an API server instance +*DefaultApi* | [**getOpenApiSpecV1**](docs/DefaultApi.md#getopenapispecv1) | **GET** /api/v1/api-server/get-open-api-spec | *DefaultApi* | [**getPrometheusMetricsV1**](docs/DefaultApi.md#getprometheusmetricsv1) | **GET** /api/v1/api-server/get-prometheus-exporter-metrics | Get the Prometheus Metrics diff --git a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt index 149d4c6e636..d05dc1394b8 100644 --- a/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt +++ b/packages/cactus-cmd-api-server/src/main/kotlin/generated/openapi/kotlin-client/src/main/kotlin/org/openapitools/client/apis/DefaultApi.kt @@ -113,6 +113,74 @@ class DefaultApi(basePath: kotlin.String = defaultBasePath, client: OkHttpClient ) } + /** + * + * Returns the openapi.json document of specific plugin. + * @return kotlin.String + * @throws IllegalStateException If the request is not correctly configured + * @throws IOException Rethrows the OkHttp execute method exception + * @throws UnsupportedOperationException If the API returns an informational or redirection response + * @throws ClientException If the API returns a client error response + * @throws ServerException If the API returns a server error response + */ + @Suppress("UNCHECKED_CAST") + @Throws(IllegalStateException::class, IOException::class, UnsupportedOperationException::class, ClientException::class, ServerException::class) + fun getOpenApiSpecV1() : kotlin.String { + val localVarResponse = getOpenApiSpecV1WithHttpInfo() + + return when (localVarResponse.responseType) { + ResponseType.Success -> (localVarResponse as Success<*>).data as kotlin.String + ResponseType.Informational -> throw UnsupportedOperationException("Client does not support Informational responses.") + ResponseType.Redirection -> throw UnsupportedOperationException("Client does not support Redirection responses.") + ResponseType.ClientError -> { + val localVarError = localVarResponse as ClientError<*> + throw ClientException("Client error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}", localVarError.statusCode, localVarResponse) + } + ResponseType.ServerError -> { + val localVarError = localVarResponse as ServerError<*> + throw ServerException("Server error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}", localVarError.statusCode, localVarResponse) + } + } + } + + /** + * + * Returns the openapi.json document of specific plugin. + * @return ApiResponse + * @throws IllegalStateException If the request is not correctly configured + * @throws IOException Rethrows the OkHttp execute method exception + */ + @Suppress("UNCHECKED_CAST") + @Throws(IllegalStateException::class, IOException::class) + fun getOpenApiSpecV1WithHttpInfo() : ApiResponse { + val localVariableConfig = getOpenApiSpecV1RequestConfig() + + return request( + localVariableConfig + ) + } + + /** + * To obtain the request config of the operation getOpenApiSpecV1 + * + * @return RequestConfig + */ + fun getOpenApiSpecV1RequestConfig() : RequestConfig { + val localVariableBody = null + val localVariableQuery: MultiValueMap = mutableMapOf() + val localVariableHeaders: MutableMap = mutableMapOf() + localVariableHeaders["Accept"] = "application/json" + + return RequestConfig( + method = RequestMethod.GET, + path = "/api/v1/api-server/get-open-api-spec", + query = localVariableQuery, + headers = localVariableHeaders, + requiresAuthentication = false, + body = localVariableBody + ) + } + /** * Get the Prometheus Metrics * diff --git a/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto index b6e0723fbdd..115e1850376 100644 --- a/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto +++ b/packages/cactus-cmd-api-server/src/main/proto/generated/openapi/services/default_service.proto @@ -18,10 +18,16 @@ import "models/health_check_response_pb.proto"; service DefaultService { rpc GetHealthCheckV1 (google.protobuf.Empty) returns (HealthCheckResponsePB); + rpc GetOpenApiSpecV1 (google.protobuf.Empty) returns (GetOpenApiSpecV1Response); + rpc GetPrometheusMetricsV1 (google.protobuf.Empty) returns (GetPrometheusMetricsV1Response); } +message GetOpenApiSpecV1Response { + string data = 1; +} + message GetPrometheusMetricsV1Response { string data = 1; } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts index 8d452a7b4e5..354b376eeed 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts @@ -1,5 +1,8 @@ import type { AddressInfo } from "net"; import type { Server as SecureServer } from "https"; +import type { Request, Response, RequestHandler } from "express"; +import type { ServerOptions as SocketIoServerOptions } from "socket.io"; +import type { Socket as SocketIoSocket } from "socket.io"; import exitHook from "async-exit-hook"; import os from "os"; import path from "path"; @@ -13,7 +16,6 @@ import fs from "fs-extra"; import expressHttpProxy from "express-http-proxy"; import { Server as GrpcServer } from "@grpc/grpc-js"; import { ServerCredentials as GrpcServerCredentials } from "@grpc/grpc-js"; -import type { Application, Request, Response, RequestHandler } from "express"; import express from "express"; import { OpenAPIV3 } from "express-openapi-validator/dist/framework/types"; import compression from "compression"; @@ -22,8 +24,6 @@ import cors from "cors"; import rateLimit from "express-rate-limit"; import { Server as SocketIoServer } from "socket.io"; -import type { ServerOptions as SocketIoServerOptions } from "socket.io"; -import type { Socket as SocketIoSocket } from "socket.io"; import { authorize as authorizeSocket } from "@thream/socketio-jwt"; import { @@ -37,7 +37,11 @@ import { PluginImportAction, } from "@hyperledger/cactus-core-api"; -import { PluginRegistry } from "@hyperledger/cactus-core"; +import { + PluginRegistry, + registerWebServiceEndpoint, +} from "@hyperledger/cactus-core"; + import { installOpenapiValidationMiddleware } from "@hyperledger/cactus-core"; import { @@ -49,7 +53,6 @@ import { import { ICactusApiServerOptions } from "./config/config-service"; import OAS from "../json/openapi.json"; -// import { OpenAPIV3 } from "express-openapi-validator/dist/framework/types"; import { PrometheusExporter } from "./prometheus-exporter/prometheus-exporter"; import { AuthorizerFactory } from "./authzn/authorizer-factory"; @@ -58,6 +61,10 @@ import { WatchHealthcheckV1Endpoint } from "./web-services/watch-healthcheck-v1- import * as default_service from "./generated/proto/protoc-gen-ts/services/default_service"; import { GrpcServerApiServer } from "./web-services/grpc/grpc-server-api-server"; import { determineAddressFamily } from "./common/determine-address-family"; +import { + GetOpenApiSpecV1Endpoint, + IGetOpenApiSpecV1EndpointOptions, +} from "./web-services/get-open-api-spec-v1-endpoint"; export interface IApiServerConstructorOptions { readonly pluginManagerOptions?: { pluginsPath: string }; @@ -91,8 +98,8 @@ export class ApiServer { private readonly httpServerCockpit?: Server | SecureServer; private readonly wsApi: SocketIoServer; private readonly grpcServer: GrpcServer; - private readonly expressApi: Application; - private readonly expressCockpit: Application; + private readonly expressApi: express.Express; + private readonly expressCockpit: express.Express; private readonly pluginsPath: string; private readonly enableShutdownHook: boolean; @@ -290,7 +297,9 @@ export class ApiServer { this.pluginRegistry = await this.initPluginRegistry(); } else { this.log.info(`getOrInitPluginRegistry() re-using injected one...`); - this.pluginRegistry = this.options.pluginRegistry; + this.pluginRegistry = await this.initPluginRegistry({ + pluginRegistry: this.options.pluginRegistry, + }); } } await this.prometheusExporter.setTotalPluginImports( @@ -305,17 +314,19 @@ export class ApiServer { } } - public async initPluginRegistry(): Promise { - const registry = new PluginRegistry({ plugins: [] }); + public async initPluginRegistry(req?: { + readonly pluginRegistry: PluginRegistry; + }): Promise { + const { pluginRegistry = new PluginRegistry({ plugins: [] }) } = req || {}; const { plugins } = this.options.config; this.log.info(`Instantiated empty registry, invoking plugin factories...`); for (const pluginImport of plugins) { - const plugin = await this.instantiatePlugin(pluginImport, registry); - registry.add(plugin); + const plugin = await this.instantiatePlugin(pluginImport, pluginRegistry); + pluginRegistry.add(plugin); } - return registry; + return pluginRegistry; } private async instantiatePlugin( @@ -347,7 +358,8 @@ export class ApiServer { // eslint-disable-next-line @typescript-eslint/no-var-requires const pluginPackage = require(/* webpackIgnore: true */ packagePath); - const createPluginFactory = pluginPackage.createPluginFactory as PluginFactoryFactory; + const createPluginFactory = + pluginPackage.createPluginFactory as PluginFactoryFactory; const pluginFactoryOptions: IPluginFactoryOptions = { pluginImportType: pluginImport.type, }; @@ -550,9 +562,27 @@ export class ApiServer { * healthcheck and monitoring information. * @param app */ - async getOrCreateWebServices(app: express.Application): Promise { + async getOrCreateWebServices(app: express.Express): Promise { const { log } = this; const { logLevel } = this.options.config; + const pluginRegistry = await this.getOrInitPluginRegistry(); + + { + const oasPath = OAS.paths["/api/v1/api-server/get-open-api-spec"]; + + const operationId = oasPath.get.operationId; + const opts: IGetOpenApiSpecV1EndpointOptions = { + oas: OAS, + oasPath, + operationId, + path: oasPath.get["x-hyperledger-cactus"].http.path, + pluginRegistry, + verbLowerCase: oasPath.get["x-hyperledger-cactus"].http.verbLowerCase, + logLevel, + }; + const endpoint = new GetOpenApiSpecV1Endpoint(opts); + await registerWebServiceEndpoint(app, endpoint); + } const healthcheckHandler = (req: Request, res: Response) => { res.json({ @@ -596,13 +626,10 @@ export class ApiServer { const { "/api/v1/api-server/get-prometheus-exporter-metrics": oasPathPrometheus, } = OAS.paths; - const { http: httpPrometheus } = oasPathPrometheus.get[ - "x-hyperledger-cactus" - ]; - const { - path: httpPathPrometheus, - verbLowerCase: httpVerbPrometheus, - } = httpPrometheus; + const { http: httpPrometheus } = + oasPathPrometheus.get["x-hyperledger-cactus"]; + const { path: httpPathPrometheus, verbLowerCase: httpVerbPrometheus } = + httpPrometheus; (app as any)[httpVerbPrometheus]( httpPathPrometheus, prometheusExporterHandler, diff --git a/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts index 66c5d2c1bed..216a4a69e5f 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/generated/openapi/typescript-axios/api.ts @@ -130,6 +130,35 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Returns the openapi.json document of specific plugin. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getOpenApiSpecV1: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/api-server/get-open-api-spec`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -189,6 +218,15 @@ export const DefaultApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getHealthCheckV1(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * Returns the openapi.json document of specific plugin. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getOpenApiSpecV1(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getOpenApiSpecV1(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @summary Get the Prometheus Metrics @@ -218,6 +256,14 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa getHealthCheckV1(options?: any): AxiosPromise { return localVarFp.getHealthCheckV1(options).then((request) => request(axios, basePath)); }, + /** + * Returns the openapi.json document of specific plugin. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getOpenApiSpecV1(options?: any): AxiosPromise { + return localVarFp.getOpenApiSpecV1(options).then((request) => request(axios, basePath)); + }, /** * * @summary Get the Prometheus Metrics @@ -248,6 +294,16 @@ export class DefaultApi extends BaseAPI { return DefaultApiFp(this.configuration).getHealthCheckV1(options).then((request) => request(this.axios, this.basePath)); } + /** + * Returns the openapi.json document of specific plugin. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public getOpenApiSpecV1(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration).getOpenApiSpecV1(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Get the Prometheus Metrics diff --git a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts index 5e9c1b4cb56..e5230cd55e6 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service.ts @@ -8,6 +8,72 @@ import * as dependency_2 from "./../models/health_check_response_pb"; import * as pb_1 from "google-protobuf"; import * as grpc_1 from "@grpc/grpc-js"; export namespace org.hyperledger.cactus.cmd_api_server { + export class GetOpenApiSpecV1EndpointResponse extends pb_1.Message { + constructor(data?: any[] | { + data?: string; + }) { + super(); + pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], []); + if (!Array.isArray(data) && typeof data == "object") { + if ("data" in data && data.data != undefined) { + this.data = data.data; + } + } + } + get data() { + return pb_1.Message.getField(this, 1) as string; + } + set data(value: string) { + pb_1.Message.setField(this, 1, value); + } + static fromObject(data: { + data?: string; + }) { + const message = new GetOpenApiSpecV1EndpointResponse({}); + if (data.data != null) { + message.data = data.data; + } + return message; + } + toObject() { + const data: { + data?: string; + } = {}; + if (this.data != null) { + data.data = this.data; + } + return data; + } + serialize(): Uint8Array; + serialize(w: pb_1.BinaryWriter): void; + serialize(w?: pb_1.BinaryWriter): Uint8Array | void { + const writer = w || new pb_1.BinaryWriter(); + if (typeof this.data === "string" && this.data.length) + writer.writeString(1, this.data); + if (!w) + return writer.getResultBuffer(); + } + static deserialize(bytes: Uint8Array | pb_1.BinaryReader): GetOpenApiSpecV1EndpointResponse { + const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new GetOpenApiSpecV1EndpointResponse(); + while (reader.nextField()) { + if (reader.isEndGroup()) + break; + switch (reader.getFieldNumber()) { + case 1: + message.data = reader.readString(); + break; + default: reader.skipField(); + } + } + return message; + } + serializeBinary(): Uint8Array { + return this.serialize(); + } + static deserializeBinary(bytes: Uint8Array): GetOpenApiSpecV1EndpointResponse { + return GetOpenApiSpecV1EndpointResponse.deserialize(bytes); + } + } export class GetPrometheusMetricsV1Response extends pb_1.Message { #one_of_decls: number[][] = []; constructor(data?: any[] | { @@ -110,6 +176,15 @@ export namespace org.hyperledger.cactus.cmd_api_server { responseSerialize: (message: dependency_2.org.hyperledger.cactus.cmd_api_server.HealthCheckResponsePB) => Buffer.from(message.serialize()), responseDeserialize: (bytes: Buffer) => dependency_2.org.hyperledger.cactus.cmd_api_server.HealthCheckResponsePB.deserialize(new Uint8Array(bytes)) }, + GetOpenApiSpecV1Endpoint: { + path: "/org.hyperledger.cactus.cmd_api_server.DefaultService/GetOpenApiSpecV1Endpoint", + requestStream: false, + responseStream: false, + requestSerialize: (message: dependency_1.google.protobuf.Empty) => Buffer.from(message.serialize()), + requestDeserialize: (bytes: Buffer) => dependency_1.google.protobuf.Empty.deserialize(new Uint8Array(bytes)), + responseSerialize: (message: GetOpenApiSpecV1EndpointResponse) => Buffer.from(message.serialize()), + responseDeserialize: (bytes: Buffer) => GetOpenApiSpecV1EndpointResponse.deserialize(new Uint8Array(bytes)) + }, GetPrometheusMetricsV1: { path: "/org.hyperledger.cactus.cmd_api_server.DefaultService/GetPrometheusMetricsV1", requestStream: false, diff --git a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_grpc_pb.d.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_grpc_pb.d.ts index e09bbd86da5..2270957a158 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_grpc_pb.d.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_grpc_pb.d.ts @@ -11,6 +11,7 @@ import * as models_health_check_response_pb_pb from "../models/health_check_resp interface IDefaultServiceService extends grpc.ServiceDefinition { getHealthCheckV1: IDefaultServiceService_IGetHealthCheckV1; + getOpenApiSpecV1: IDefaultServiceService_IGetOpenApiSpecV1; getPrometheusMetricsV1: IDefaultServiceService_IGetPrometheusMetricsV1; } @@ -23,6 +24,15 @@ interface IDefaultServiceService_IGetHealthCheckV1 extends grpc.MethodDefinition responseSerialize: grpc.serialize; responseDeserialize: grpc.deserialize; } +interface IDefaultServiceService_IGetOpenApiSpecV1 extends grpc.MethodDefinition { + path: "/org.hyperledger.cactus.cmd_api_server.DefaultService/GetOpenApiSpecV1"; + requestStream: false; + responseStream: false; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} interface IDefaultServiceService_IGetPrometheusMetricsV1 extends grpc.MethodDefinition { path: "/org.hyperledger.cactus.cmd_api_server.DefaultService/GetPrometheusMetricsV1"; requestStream: false; @@ -37,6 +47,7 @@ export const DefaultServiceService: IDefaultServiceService; export interface IDefaultServiceServer extends grpc.UntypedServiceImplementation { getHealthCheckV1: grpc.handleUnaryCall; + getOpenApiSpecV1: grpc.handleUnaryCall; getPrometheusMetricsV1: grpc.handleUnaryCall; } @@ -44,6 +55,9 @@ export interface IDefaultServiceClient { getHealthCheckV1(request: google_protobuf_empty_pb.Empty, callback: (error: grpc.ServiceError | null, response: models_health_check_response_pb_pb.HealthCheckResponsePB) => void): grpc.ClientUnaryCall; getHealthCheckV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: models_health_check_response_pb_pb.HealthCheckResponsePB) => void): grpc.ClientUnaryCall; getHealthCheckV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: models_health_check_response_pb_pb.HealthCheckResponsePB) => void): grpc.ClientUnaryCall; + getOpenApiSpecV1(request: google_protobuf_empty_pb.Empty, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetOpenApiSpecV1Response) => void): grpc.ClientUnaryCall; + getOpenApiSpecV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetOpenApiSpecV1Response) => void): grpc.ClientUnaryCall; + getOpenApiSpecV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetOpenApiSpecV1Response) => void): grpc.ClientUnaryCall; getPrometheusMetricsV1(request: google_protobuf_empty_pb.Empty, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetPrometheusMetricsV1Response) => void): grpc.ClientUnaryCall; getPrometheusMetricsV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetPrometheusMetricsV1Response) => void): grpc.ClientUnaryCall; getPrometheusMetricsV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetPrometheusMetricsV1Response) => void): grpc.ClientUnaryCall; @@ -54,6 +68,9 @@ export class DefaultServiceClient extends grpc.Client implements IDefaultService public getHealthCheckV1(request: google_protobuf_empty_pb.Empty, callback: (error: grpc.ServiceError | null, response: models_health_check_response_pb_pb.HealthCheckResponsePB) => void): grpc.ClientUnaryCall; public getHealthCheckV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: models_health_check_response_pb_pb.HealthCheckResponsePB) => void): grpc.ClientUnaryCall; public getHealthCheckV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: models_health_check_response_pb_pb.HealthCheckResponsePB) => void): grpc.ClientUnaryCall; + public getOpenApiSpecV1(request: google_protobuf_empty_pb.Empty, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetOpenApiSpecV1Response) => void): grpc.ClientUnaryCall; + public getOpenApiSpecV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetOpenApiSpecV1Response) => void): grpc.ClientUnaryCall; + public getOpenApiSpecV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetOpenApiSpecV1Response) => void): grpc.ClientUnaryCall; public getPrometheusMetricsV1(request: google_protobuf_empty_pb.Empty, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetPrometheusMetricsV1Response) => void): grpc.ClientUnaryCall; public getPrometheusMetricsV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetPrometheusMetricsV1Response) => void): grpc.ClientUnaryCall; public getPrometheusMetricsV1(request: google_protobuf_empty_pb.Empty, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: services_default_service_pb.GetPrometheusMetricsV1Response) => void): grpc.ClientUnaryCall; diff --git a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_pb.d.ts b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_pb.d.ts index 24d7c003953..2c4bf0b1eeb 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_pb.d.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/generated/proto/protoc-gen-ts/services/default_service_pb.d.ts @@ -8,6 +8,26 @@ import * as jspb from "google-protobuf"; import * as google_protobuf_empty_pb from "google-protobuf/google/protobuf/empty_pb"; import * as models_health_check_response_pb_pb from "../models/health_check_response_pb_pb"; +export class GetOpenApiSpecV1Response extends jspb.Message { + getData(): string; + setData(value: string): GetOpenApiSpecV1Response; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): GetOpenApiSpecV1Response.AsObject; + static toObject(includeInstance: boolean, msg: GetOpenApiSpecV1Response): GetOpenApiSpecV1Response.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: GetOpenApiSpecV1Response, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): GetOpenApiSpecV1Response; + static deserializeBinaryFromReader(message: GetOpenApiSpecV1Response, reader: jspb.BinaryReader): GetOpenApiSpecV1Response; +} + +export namespace GetOpenApiSpecV1Response { + export type AsObject = { + data: string, + } +} + export class GetPrometheusMetricsV1Response extends jspb.Message { getData(): string; setData(value: string): GetPrometheusMetricsV1Response; diff --git a/packages/cactus-cmd-api-server/src/main/typescript/openapi/get-open-api-spec.ts b/packages/cactus-cmd-api-server/src/main/typescript/openapi/get-open-api-spec.ts new file mode 100644 index 00000000000..bf9009fff43 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/openapi/get-open-api-spec.ts @@ -0,0 +1,48 @@ +import { + Checks, + LogLevelDesc, + LoggerProvider, +} from "@hyperledger/cactus-common"; +import { PluginRegistry } from "@hyperledger/cactus-core"; +import { + ICactusPlugin, + IPluginWebService, + isIPluginWebService, +} from "@hyperledger/cactus-core-api"; +import type { OpenAPIV3 } from "express-openapi-validator/dist/framework/types"; + +export async function getOpenApiSpecV1(req: { + readonly pluginRegistry: PluginRegistry; + readonly logLevel?: LogLevelDesc; +}): Promise { + const fnTag = `cactus-cmd-api-server/openapi/get-open-api-spec.ts#getOpenApiSpecV1()`; + Checks.truthy(req, `${fnTag} req`); + Checks.truthy(req.pluginRegistry, `${fnTag} req.pluginRegistry`); + const { pluginRegistry, logLevel = "INFO" } = req; + + const log = LoggerProvider.getOrCreate({ + label: fnTag, + level: logLevel, + }); + + const allPlugins = pluginRegistry.getPlugins(); + + log.debug("Pulled a total of %o plugins from registry.", allPlugins.length); + + const webSvcPlugins = allPlugins.filter((p) => isIPluginWebService(p)); + + log.debug("Found %o web service plugins.", webSvcPlugins.length); + + const openApiJsonSpecsPromises = webSvcPlugins.map( + async (plugin: ICactusPlugin) => { + const pkgName = plugin.getPackageName(); + log.debug("Getting OpenAPI spec for %s", pkgName); + const webPlugin = plugin as IPluginWebService; + const openApiSpec = await webPlugin.getOpenApiSpec(); + return openApiSpec as OpenAPIV3.Document; + }, + ); + + const openApiJsonSpecs = await Promise.all(openApiJsonSpecsPromises); + return openApiJsonSpecs; +} diff --git a/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts b/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts new file mode 100644 index 00000000000..79b3e6d4b0c --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/web-services/get-open-api-spec-v1-endpoint.ts @@ -0,0 +1,37 @@ +import { + GetOpenApiSpecV1EndpointBase, + IGetOpenApiSpecV1EndpointBaseOptions, +} from "@hyperledger/cactus-core"; + +import { Checks, LogLevelDesc } from "@hyperledger/cactus-common"; +import { IWebServiceEndpoint } from "@hyperledger/cactus-core-api"; + +import OAS from "../../json/openapi.json"; + +export const OasPathGetOpenApiSpecV1 = + OAS.paths["/api/v1/api-server/get-open-api-spec"]; + +export type OasPathTypeGetOpenApiSpecV1 = typeof OasPathGetOpenApiSpecV1; + +export interface IGetOpenApiSpecV1EndpointOptions + extends IGetOpenApiSpecV1EndpointBaseOptions< + typeof OAS, + OasPathTypeGetOpenApiSpecV1 + > { + readonly logLevel?: LogLevelDesc; +} + +export class GetOpenApiSpecV1Endpoint + extends GetOpenApiSpecV1EndpointBase + implements IWebServiceEndpoint +{ + public get className(): string { + return GetOpenApiSpecV1Endpoint.CLASS_NAME; + } + + constructor(public readonly options: IGetOpenApiSpecV1EndpointOptions) { + super(options); + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + } +} diff --git a/packages/cactus-cmd-api-server/src/main/typescript/web-services/grpc/grpc-server-api-server.ts b/packages/cactus-cmd-api-server/src/main/typescript/web-services/grpc/grpc-server-api-server.ts index b6ae3f2063a..6bdd43d7d37 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/web-services/grpc/grpc-server-api-server.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/web-services/grpc/grpc-server-api-server.ts @@ -42,4 +42,17 @@ export class GrpcServerApiServer extends default_service.org.hyperledger.cactus const res = new default_service.org.hyperledger.cactus.cmd_api_server.GetPrometheusMetricsV1Response(); callback(null, res); } + + GetOpenApiSpecV1Endpoint( + call: ServerUnaryCall< + Empty, + default_service.org.hyperledger.cactus.cmd_api_server.GetOpenApiSpecV1EndpointResponse + >, + callback: requestCallback< + default_service.org.hyperledger.cactus.cmd_api_server.GetOpenApiSpecV1EndpointResponse + >, + ): void { + const res = new default_service.org.hyperledger.cactus.cmd_api_server.GetOpenApiSpecV1EndpointResponse(); + callback(null, res); + } } diff --git a/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts new file mode 100644 index 00000000000..ecf2e696820 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/get-open-api-spec-v1-endpoint.test.ts @@ -0,0 +1,119 @@ +import { + ApiServer, + ApiServerApiClient, + ApiServerApiClientConfiguration, + AuthorizationProtocol, + ConfigService, + IAuthorizationConfig, +} from "@hyperledger/cactus-cmd-api-server"; +import { + IJoseFittingJwtParams, + LogLevelDesc, +} from "@hyperledger/cactus-common"; +import { PluginRegistry } from "@hyperledger/cactus-core"; +import { Constants } from "@hyperledger/cactus-core-api"; +import type { AuthorizeOptions as SocketIoJwtOptions } from "@thream/socketio-jwt"; +import type { Params as ExpressJwtOptions } from "express-jwt"; +import "jest-extended"; +import { SignJWT, exportSPKI, generateKeyPair } from "jose"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; + +describe("cmd-api-server:getOpenApiSpecV1Endpoint", () => { + const logLevel: LogLevelDesc = "TRACE"; + let apiServer: ApiServer; + let apiClient: ApiServerApiClient; + + afterAll(() => apiServer.shutdown()); + + beforeAll(async () => { + const jwtKeyPair = await generateKeyPair("RS256", { modulusLength: 4096 }); + const jwtPublicKey = await exportSPKI(jwtKeyPair.publicKey); + const expressJwtOptions: ExpressJwtOptions & IJoseFittingJwtParams = { + algorithms: ["RS256"], + secret: jwtPublicKey, + audience: uuidv4(), + issuer: uuidv4(), + }; + const socketIoJwtOptions: SocketIoJwtOptions = { + secret: jwtPublicKey, + algorithms: ["RS256"], + }; + expect(expressJwtOptions).toBeTruthy(); + + const authorizationConfig: IAuthorizationConfig = { + unprotectedEndpointExemptions: [], + expressJwtOptions, + socketIoJwtOptions, + socketIoPath: Constants.SocketIoConnectionPathV1, + }; + + const pluginsPath = path.join( + __dirname, + "../../../../../../", // walk back up to the project root + ".tmp/test/test-cmd-api-server/get-open-api-spec-v1-endpoint_test/", // the dir path from the root + uuidv4(), // then a random directory to ensure proper isolation + ); + const pluginManagerOptionsJson = JSON.stringify({ pluginsPath }); + + const pluginRegistry = new PluginRegistry({ logLevel }); + + const configService = new ConfigService(); + + const apiSrvOpts = await configService.newExampleConfig(); + apiSrvOpts.pluginManagerOptionsJson = pluginManagerOptionsJson; + apiSrvOpts.authorizationProtocol = AuthorizationProtocol.JSON_WEB_TOKEN; + apiSrvOpts.authorizationConfigJson = authorizationConfig; + apiSrvOpts.configFile = ""; + apiSrvOpts.apiCorsDomainCsv = "*"; + apiSrvOpts.apiPort = 0; + apiSrvOpts.cockpitPort = 0; + apiSrvOpts.grpcPort = 0; + apiSrvOpts.apiTlsEnabled = false; + apiSrvOpts.plugins = []; + + const config = await configService.newExampleConfigConvict(apiSrvOpts); + + apiServer = new ApiServer({ + config: config.getProperties(), + pluginRegistry, + }); + + const startResponsePromise = apiServer.start(); + await expect(startResponsePromise).toResolve(); + const startResponse = await startResponsePromise; + expect(startResponse).toBeTruthy(); + + const addressInfoApi = (await startResponsePromise).addressInfoApi; + const protocol = apiSrvOpts.apiTlsEnabled ? "https" : "http"; + const { address, port } = addressInfoApi; + const apiHost = `${protocol}://${address}:${port}`; + + const jwtPayload = { name: "Peter", location: "Albertirsa" }; + const validJwt = await new SignJWT(jwtPayload) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(expressJwtOptions.issuer) + .setAudience(expressJwtOptions.audience) + .sign(jwtKeyPair.privateKey); + expect(validJwt).toBeTruthy(); + + const validBearerToken = `Bearer ${validJwt}`; + expect(validBearerToken).toBeTruthy(); + + apiClient = new ApiServerApiClient( + new ApiServerApiClientConfiguration({ + basePath: apiHost, + baseOptions: { headers: { Authorization: validBearerToken } }, + logLevel, + }), + ); + }); + + it("returns the OpenAPI spec .json document of the API server itself", async () => { + const res1Promise = apiClient.getOpenApiSpecV1(); + await expect(res1Promise).resolves.toHaveProperty("data.openapi"); + const res1 = await res1Promise; + expect(res1.status).toEqual(200); + expect(res1.data).toBeTruthy(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 5da15300830..6060708bd37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6294,6 +6294,7 @@ __metadata: protobufjs: 7.2.4 run-time-error: 1.4.0 rxjs: 7.8.1 + safe-stable-stringify: 2.4.3 semver: 7.5.2 socket.io: 4.5.4 socket.io-client: 4.5.4