From c8ff3f919d1886c07f0c4adf0271da1987584c45 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 10:57:00 +0800 Subject: [PATCH 001/176] add mongoose model for model api fields --- ai-verify-apigw/models/model.model.mjs | 55 ++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index a1886f6dc..5cb4b09b7 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -1,10 +1,60 @@ import { Schema, model } from 'mongoose'; +const PRIMITIVE_TYPES = ["string", "number", "integer", "boolean"]; +const ALL_TYPES = [...PRIMITIVE_TYPES, "array", "object"]; + +const modelAPISchema = new Schema({ + method: { type: String, required: true, enum: ["POST", "GET"] }, + url: { type: String, required: true }, + authType: { type: String, required: true, enum: [ "No Auth", "Bearer Token", "Basic Auth" ], default:"No Auth" }, + authTypeConfig: { type: Object }, + additionalHeaders: [{ + name: { type: String, required: true }, + type: { type: String, required: true, enum: PRIMITIVE_TYPES }, + value: { type: Object, required: true }, + }], + parameters: { + subpath: { type: String }, + pathParams: { + name: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + style: { type: String, enum: ["simple","label","matrix"], default: "simple" }, + explode: { type: Boolean, default: false } + }, + queryParams: { + name: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + style: { type: String, enum: ["form","spaceDelimited","pipeDelimited","deepObject"], default: "form" }, + explode: { type: Boolean, default: true } + } + }, // parameters + requestBody: { + mediaType: { type: String, required: true, enum: ["none","multipart/form-data","application/x-www-form-urlencoded"] }, + params: { type: [String] }, // array of param keys + properties: { + field: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + style: { type: String, enum: ["form","spaceDelimited","pipeDelimited","deepObject"], default: "form" }, + explode: { type: Boolean, default: true } + } + }, + response: { + statusCode: { type: Number, required: true, default: 200 }, + mediaType: { type: String, required: true, enum: ["text/plain","application/json"] }, + type: { type: String, enum: ALL_TYPES, default:"integer" }, + field: { type: String }, // for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + } +}) const modelFileSchema = new Schema({ - filename: { type: String, required: true }, name: { type: String, required: true }, - filePath: { type: String, required: true }, + type: { type: String, required: true, default: "File", enum: ["File","Folder","Pipeline","API"] }, + filename: { type: String }, // for non-API type + filePath: { type: String }, // for non-API type + modelAPI: { type: modelAPISchema }, // for API type ctime: { type: Date }, description: { type: String, required: false }, status: { type: String, default: "Pending", enum: ["Pending","Valid","Invalid","Error","Cancelled","Temp"] }, @@ -13,7 +63,6 @@ const modelFileSchema = new Schema({ serializer: { type: String }, modelFormat: { type: String }, errorMessages: { type: String }, - type: { type: String, default: "File", enum: ["File","Folder","Pipeline"] }, }, { timestamps: { createdAt: true, updatedAt: true } }); From ac6bde21eac5b46ee06c4542682a84b7a05b61eb Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 10:57:27 +0800 Subject: [PATCH 002/176] add custom scalars for openapi types --- .../graphql/modules/scalars/index.mjs | 7 ++++ .../modules/scalars/openapiScalars.graphql | 1 + .../modules/scalars/openapiScalars.mjs | 33 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 ai-verify-apigw/graphql/modules/scalars/index.mjs create mode 100644 ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql create mode 100644 ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs diff --git a/ai-verify-apigw/graphql/modules/scalars/index.mjs b/ai-verify-apigw/graphql/modules/scalars/index.mjs new file mode 100644 index 000000000..761295b94 --- /dev/null +++ b/ai-verify-apigw/graphql/modules/scalars/index.mjs @@ -0,0 +1,7 @@ +import { OpenAPIMediaType } from './openapiScalars.mjs'; + +const MyScalars = { + OpenAPIMediaType +} + +export default MyScalars; \ No newline at end of file diff --git a/ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql new file mode 100644 index 000000000..40526a81c --- /dev/null +++ b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql @@ -0,0 +1 @@ +scalar OpenAPIMediaType diff --git a/ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs new file mode 100644 index 000000000..8ed2763e3 --- /dev/null +++ b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs @@ -0,0 +1,33 @@ +import { GraphQLScalarType, GraphQLError, Kind } from 'graphql'; + +// Validation function for checking MediaTypes +function _mediaType(value) { + if (typeof value === 'string') { + switch (value) { + case "none": + case "application/x-www-form-urlencoded": + case "multipart/form-data": + case "application/json": + case "text/plain": + return value; + } + } + throw new GraphQLError('Provided value is not a valid media type', { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +export const OpenAPIMediaType = new GraphQLScalarType({ + name: 'OpenAPIMediaType', + description: 'OpenAPI MediaType', + serialize: _mediaType, + parseValue: _mediaType, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return new _mediaType(ast.value); + } + throw new GraphQLError('Provided value is not a valid media type', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + }, +}); \ No newline at end of file From 6f62ed7bf4bc02a3c6bded68b49f5ca4f5ab090f Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 10:57:41 +0800 Subject: [PATCH 003/176] add custom scalars for openapi types --- ai-verify-apigw/graphql/resolvers.mjs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ai-verify-apigw/graphql/resolvers.mjs b/ai-verify-apigw/graphql/resolvers.mjs index 6d28b2369..a94e843c1 100644 --- a/ai-verify-apigw/graphql/resolvers.mjs +++ b/ai-verify-apigw/graphql/resolvers.mjs @@ -1,8 +1,7 @@ -import path from 'node:path'; -import process from 'node:process'; // import { loadFilesSync } from '@graphql-tools/load-files'; import { mergeResolvers } from '@graphql-tools/merge'; +import MyScalars from './modules/scalars/index.mjs'; import ProjectResolvers from './modules/project/project.mjs'; import DatasetResolvers from './modules/assets/dataset.mjs'; import ModelResolvers from './modules/assets/model.mjs'; @@ -12,6 +11,6 @@ import NotificationResolvers from './modules/notification/notification.mjs'; // const resolversArray = loadFilesSync(path.join(process.cwd(), 'server/graphql/modules/**/*.ts'), { recursive: true }) // console.log("resolversArray", resolversArray) // const resolvers = mergeResolvers(resolversArray); -const resolvers = mergeResolvers([ProjectResolvers, ProjectTemplateResolvers, DatasetResolvers, ModelResolvers, NotificationResolvers]); +const resolvers = mergeResolvers([MyScalars, ProjectResolvers, ProjectTemplateResolvers, DatasetResolvers, ModelResolvers, NotificationResolvers]); export default resolvers; \ No newline at end of file From 0f4983e4b71b23b4cfd76da7a8602c39bbb3bcab Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 10:57:58 +0800 Subject: [PATCH 004/176] add graphql definitions for openapi --- ai-verify-apigw/graphql/modules/assets/model.graphql | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.graphql b/ai-verify-apigw/graphql/modules/assets/model.graphql index d3cb302dd..289475163 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.graphql +++ b/ai-verify-apigw/graphql/modules/assets/model.graphql @@ -2,7 +2,7 @@ enum ModelAccessType { File Folder Pipeline - # API + API } enum ModelType { @@ -20,9 +20,11 @@ enum ModelFileStatusType { type ModelFile { id: ObjectID! - filename: String! name: String! - filePath: String! + type: ModelAccessType! + filename: String # if type is not API + filePath: String # if type is not API + modelAPI: ModelAPIType # if type is API ctime: DateTime description: String status: ModelFileStatusType @@ -31,7 +33,6 @@ type ModelFile { serializer: String modelFormat: String errorMessages: String - type: ModelAccessType! } input ModelFileInput { @@ -41,7 +42,7 @@ input ModelFileInput { # filePath: String # ctime: DateTime description: String @constraint(minLength: 0, maxLength: 256) - status: ModelFileStatusType + # status: ModelFileStatusType # size: String modelType: ModelType # serializer: String From 5343c3ec019c8b598f4fa30cdabf4829722a8e5b Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 11:50:07 +0800 Subject: [PATCH 005/176] remove assets/ --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8d9840a61..83caf9d7f 100644 --- a/.gitignore +++ b/.gitignore @@ -168,7 +168,6 @@ temp/ .gitignore # Testing files -assets/ ci-central/ cov-badge.svg coverage.json From ba3b90e330e960bcb2f8d15b6a65bb2776d9f517 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 11:53:31 +0800 Subject: [PATCH 006/176] add modelAPI to ModelFileInput --- .../graphql/modules/assets/model.graphql | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.graphql b/ai-verify-apigw/graphql/modules/assets/model.graphql index 289475163..919aa896b 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.graphql +++ b/ai-verify-apigw/graphql/modules/assets/model.graphql @@ -33,22 +33,15 @@ type ModelFile { serializer: String modelFormat: String errorMessages: String + createdAt: DateTime + updatedAt: DateTime } input ModelFileInput { - # id: ObjectID - # filename: String name: String @constraint(minLength: 0, maxLength: 128) - # filePath: String - # ctime: DateTime description: String @constraint(minLength: 0, maxLength: 256) - # status: ModelFileStatusType - # size: String modelType: ModelType - # serializer: String - # modelFormat: String - # errorMessages: String - # type: String + modelAPI: ModelAPIInput # for model API inputs } type Query { From 4647b9f7e7a82e60a577c1cb57898abc0ad7f252 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 11:53:50 +0800 Subject: [PATCH 007/176] graphql definitions for model api --- .../graphql/modules/assets/modelapi.graphql | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 ai-verify-apigw/graphql/modules/assets/modelapi.graphql diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql new file mode 100644 index 000000000..b3d5dcb33 --- /dev/null +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -0,0 +1,158 @@ +enum OpenAPIPrimitiveTypes { + string + number + integer + boolean +} + +enum OpenAPIAllTypes { + string + number + integer + boolean + array + object +} + +enum OpenAPIMethodType { + POST + GET +} + +type OpenAPIAdditionalHeadersType { + name: String! @constraint(minLength:1, maxLength:128) + type: OpenAPIPrimitiveTypes! + value: JSON! +} + +enum OpenAPIPathParamsStyles { + simple + label + matrix +} + +type OpenAPIPathParamsType { + name: String! @constraint(minLength:1, maxLength:128) + type: OpenAPIAllTypes! + itemType: OpenAPIPrimitiveTypes! + style: OpenAPIPathParamsStyles + explode: Boolean +} + +enum OpenAPIQueryParamsStyles { + form + spaceDelimited + pipeDelimited + deepObject +} + +type QueryParamsType { + name: String! @constraint(minLength:1, maxLength:128) + type: OpenAPIAllTypes! + itemType: OpenAPIPrimitiveTypes! + style: OpenAPIQueryParamsStyles + explode: Boolean +} + +type OpenAPIParametersType { + subpath: String @constraint(minLength:1, maxLength:2048) + pathParams: OpenAPIPathParamsType + queryParams: QueryParamsType +} + + +type OpenAPIRequestBodyPropertyType { + field: String! @constraint(minLength:1, maxLength:128) + type: OpenAPIAllTypes! + itemType: OpenAPIPrimitiveTypes! + style: OpenAPIQueryParamsStyles + explode: Boolean +} + + +type OpenAPIRequestBodyType { + mediaType: OpenAPIMediaType! + params: [String] @constraint(minItems: 1, maxItems:128) # array of param keys + properties: [OpenAPIRequestBodyPropertyType] +} + +type OpenAPIResponseType { + statusCode: Int! @constraint(min:200, max:299) + mediaType: OpenAPIMediaType! + type: OpenAPIAllTypes + field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + +} + +type ModelAPIType { + method: OpenAPIMethodType! + url: URL! + authType: String! @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") + authTypeConfig: JSON + additionalHeaders: [OpenAPIAdditionalHeadersType], + parameters: OpenAPIParametersType + requestBody: OpenAPIRequestBodyType + response: OpenAPIResponseType +} + + +input OpenAPIAdditionalHeadersInput { + name: String @constraint(minLength:1, maxLength:128) + type: OpenAPIPrimitiveTypes! + value: JSON! +} + +input OpenAPIPathParamsInput { + name: String @constraint(minLength:1, maxLength:128) + type: OpenAPIAllTypes! + itemType: OpenAPIPrimitiveTypes! + style: OpenAPIPathParamsStyles + explode: Boolean +} + +input QueryParamsInput { + name: String @constraint(minLength:1, maxLength:128) + type: OpenAPIAllTypes! + itemType: OpenAPIPrimitiveTypes! + style: OpenAPIQueryParamsStyles + explode: Boolean +} + +input OpenAPIParametersInput { + subpath: String @constraint(minLength:1, maxLength:2048) + pathParams: OpenAPIPathParamsInput + queryParams: QueryParamsInput +} + +input OpenAPIRequestBodyPropertyInput { + field: String @constraint(minLength:1, maxLength:128) + type: OpenAPIAllTypes! + itemType: OpenAPIPrimitiveTypes! + style: OpenAPIQueryParamsStyles + explode: Boolean +} + +input OpenAPIRequestBodyInput { + mediaType: OpenAPIMediaType + params: [String] @constraint(minItems: 1, maxItems:128) # array of param keys + properties: [OpenAPIRequestBodyPropertyInput] +} + +input OpenAPIResponseInput { + statusCode: Int @constraint(min:200, max:299) + mediaType: OpenAPIMediaType! + type: OpenAPIAllTypes + field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + +} + +input ModelAPIInput { + method: OpenAPIMethodType + url: URL + authType: String @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") + authTypeConfig: JSON + additionalHeaders: [OpenAPIAdditionalHeadersInput] + parameters: OpenAPIParametersInput + requestBody: OpenAPIRequestBodyInput + response: OpenAPIResponseInput +} \ No newline at end of file From b734333a009d5d199022312fe6b0ac8197c9ff6f Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 13:23:20 +0800 Subject: [PATCH 008/176] add request config section --- .../graphql/modules/assets/modelapi.graphql | 69 ++++++++++++------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index b3d5dcb33..13bf1c891 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -77,22 +77,35 @@ type OpenAPIRequestBodyType { } type OpenAPIResponseType { - statusCode: Int! @constraint(min:200, max:299) - mediaType: OpenAPIMediaType! - type: OpenAPIAllTypes - field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + statusCode: Int! @constraint(min:200, max:299) + mediaType: OpenAPIMediaType! + type: OpenAPIAllTypes + field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field +} + +enum ModelAPIRequestConfigBatchStrategy { + none + multipart +} +type ModelAPIRequestConfigType { + rateLimit: Int! @constraint(min:-1) # max number of requests per minute + batchStrategy: ModelAPIRequestConfigBatchStrategy! + batchLimit: Int @constraint(min:-1) # max number of requests in each batch + maxConnections: Int! @constraint(min:-1) # max number of concurrent connections to API server + requestTimeout: Int! @constraint(min:1) # request connection timeout in ms } type ModelAPIType { - method: OpenAPIMethodType! - url: URL! - authType: String! @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") - authTypeConfig: JSON - additionalHeaders: [OpenAPIAdditionalHeadersType], - parameters: OpenAPIParametersType - requestBody: OpenAPIRequestBodyType - response: OpenAPIResponseType + method: OpenAPIMethodType! + url: URL! + authType: String! @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") + authTypeConfig: JSON! + additionalHeaders: [OpenAPIAdditionalHeadersType], + parameters: OpenAPIParametersType + requestBody: OpenAPIRequestBodyType + response: OpenAPIResponseType! + requestConfig: ModelAPIRequestConfigType! } @@ -139,20 +152,28 @@ input OpenAPIRequestBodyInput { } input OpenAPIResponseInput { - statusCode: Int @constraint(min:200, max:299) - mediaType: OpenAPIMediaType! - type: OpenAPIAllTypes - field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + statusCode: Int @constraint(min:200, max:299) + mediaType: OpenAPIMediaType! + type: OpenAPIAllTypes + field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field +} +input ModelAPIRequestConfigInput { + rateLimit: Int @constraint(min:-1) # max number of requests per minute + batchStrategy: ModelAPIRequestConfigBatchStrategy + batchLimit: Int @constraint(min:-1) # max number of requests in each batch + maxConnections: Int @constraint(min:-1) # max number of concurrent connections to API server + requestTimeout: Int @constraint(min:1) # request connection timeout in ms } input ModelAPIInput { - method: OpenAPIMethodType - url: URL - authType: String @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") - authTypeConfig: JSON - additionalHeaders: [OpenAPIAdditionalHeadersInput] - parameters: OpenAPIParametersInput - requestBody: OpenAPIRequestBodyInput - response: OpenAPIResponseInput + method: OpenAPIMethodType + url: URL + authType: String @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") + authTypeConfig: JSON + additionalHeaders: [OpenAPIAdditionalHeadersInput] + parameters: OpenAPIParametersInput + requestBody: OpenAPIRequestBodyInput + response: OpenAPIResponseInput + requestConfig: ModelAPIRequestConfigInput } \ No newline at end of file From 5453b08df3658c68ffc8cf03a1bfe16066cb90d3 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 15:12:21 +0800 Subject: [PATCH 009/176] add requestConfig --- ai-verify-apigw/models/model.model.mjs | 128 ++++++++++++++----------- 1 file changed, 70 insertions(+), 58 deletions(-) diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 5cb4b09b7..2f3562421 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -3,68 +3,80 @@ import { Schema, model } from 'mongoose'; const PRIMITIVE_TYPES = ["string", "number", "integer", "boolean"]; const ALL_TYPES = [...PRIMITIVE_TYPES, "array", "object"]; +const modelAPIAdditionalHeadersSchema = new Schema({ + name: { type: String, required: true }, + type: { type: String, required: true, enum: PRIMITIVE_TYPES }, + value: { type: Object, required: true }, +}) + +const modelAPIParametersSchema = new Schema({ + subpath: { type: String }, + pathParams: [{ + name: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + style: { type: String, enum: ["simple", "label", "matrix"], default: "simple" }, + explode: { type: Boolean, default: false } + }], + queryParams: [{ + name: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + style: { type: String, enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], default: "form" }, + explode: { type: Boolean, default: true } + }] +}) + +const modelAPIRequestBodySchema = new Schema({ + mediaType: { type: String, required: true, enum: ["none", "multipart/form-data", "application/x-www-form-urlencoded"] }, + properties: [{ + field: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + style: { type: String, enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], default: "form" }, + explode: { type: Boolean, default: true } + }] +}) + const modelAPISchema = new Schema({ - method: { type: String, required: true, enum: ["POST", "GET"] }, - url: { type: String, required: true }, - authType: { type: String, required: true, enum: [ "No Auth", "Bearer Token", "Basic Auth" ], default:"No Auth" }, - authTypeConfig: { type: Object }, - additionalHeaders: [{ - name: { type: String, required: true }, - type: { type: String, required: true, enum: PRIMITIVE_TYPES }, - value: { type: Object, required: true }, - }], - parameters: { - subpath: { type: String }, - pathParams: { - name: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - style: { type: String, enum: ["simple","label","matrix"], default: "simple" }, - explode: { type: Boolean, default: false } - }, - queryParams: { - name: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - style: { type: String, enum: ["form","spaceDelimited","pipeDelimited","deepObject"], default: "form" }, - explode: { type: Boolean, default: true } - } - }, // parameters - requestBody: { - mediaType: { type: String, required: true, enum: ["none","multipart/form-data","application/x-www-form-urlencoded"] }, - params: { type: [String] }, // array of param keys - properties: { - field: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - style: { type: String, enum: ["form","spaceDelimited","pipeDelimited","deepObject"], default: "form" }, - explode: { type: Boolean, default: true } - } - }, - response: { - statusCode: { type: Number, required: true, default: 200 }, - mediaType: { type: String, required: true, enum: ["text/plain","application/json"] }, - type: { type: String, enum: ALL_TYPES, default:"integer" }, - field: { type: String }, // for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field - } + method: { type: String, required: true, enum: ["POST", "GET"] }, + url: { type: String, required: true }, + authType: { type: String, required: true, enum: ["No Auth", "Bearer Token", "Basic Auth"], default: "No Auth" }, + authTypeConfig: { type: Object }, + additionalHeaders: [modelAPIAdditionalHeadersSchema], + parameters: modelAPIParametersSchema, + requestBody: modelAPIRequestBodySchema, + response: { + statusCode: { type: Number, required: true, default: 200 }, + mediaType: { type: String, required: true, enum: ["text/plain", "application/json"] }, + type: { type: String, enum: ALL_TYPES, default: "integer" }, + field: { type: String }, // for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + }, + requestConfig: { + rateLimit: { type: Number, required: true }, + batchStrategy: { type: String, required: true, enum: ["none","multipart"] }, + batchLimit: { type: Number }, + maxConnections: { type: Number, required: true }, + requestTimeout: { type: Number, required: true }, + } }) const modelFileSchema = new Schema({ - name: { type: String, required: true }, - type: { type: String, required: true, default: "File", enum: ["File","Folder","Pipeline","API"] }, - filename: { type: String }, // for non-API type - filePath: { type: String }, // for non-API type - modelAPI: { type: modelAPISchema }, // for API type - ctime: { type: Date }, - description: { type: String, required: false }, - status: { type: String, default: "Pending", enum: ["Pending","Valid","Invalid","Error","Cancelled","Temp"] }, - size: { type: String }, - modelType: { type: String, required: false, enum: ["Classification","Regression"] }, - serializer: { type: String }, - modelFormat: { type: String }, - errorMessages: { type: String }, -}, { - timestamps: { createdAt: true, updatedAt: true } + name: { type: String, required: true }, + type: { type: String, required: true, default: "File", enum: ["File", "Folder", "Pipeline", "API"] }, + filename: { type: String }, // for non-API type + filePath: { type: String }, // for non-API type + modelAPI: { type: modelAPISchema }, // for API type + ctime: { type: Date }, + description: { type: String, required: false }, + status: { type: String, default: "Pending", enum: ["Pending", "Valid", "Invalid", "Error", "Cancelled", "Temp"] }, + size: { type: String }, + modelType: { type: String, required: false, enum: ["Classification", "Regression"] }, + serializer: { type: String }, + modelFormat: { type: String }, + errorMessages: { type: String }, +}, { + timestamps: { createdAt: true, updatedAt: true } }); export const ModelFileModel = model('ModelFileModel', modelFileSchema); From d050c6b882a3da968bf5ea2366c4c3a6d8d0b582 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 15:12:50 +0800 Subject: [PATCH 010/176] add custom scaler OpenAPIAuthType --- .../graphql/modules/scalars/index.mjs | 5 ++-- .../modules/scalars/openapiScalars.graphql | 1 + .../modules/scalars/openapiScalars.mjs | 30 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/scalars/index.mjs b/ai-verify-apigw/graphql/modules/scalars/index.mjs index 761295b94..c1c44bc14 100644 --- a/ai-verify-apigw/graphql/modules/scalars/index.mjs +++ b/ai-verify-apigw/graphql/modules/scalars/index.mjs @@ -1,7 +1,8 @@ -import { OpenAPIMediaType } from './openapiScalars.mjs'; +import { OpenAPIMediaType, OpenAPIAuthType } from './openapiScalars.mjs'; const MyScalars = { - OpenAPIMediaType + OpenAPIMediaType, + OpenAPIAuthType } export default MyScalars; \ No newline at end of file diff --git a/ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql index 40526a81c..ffbd427cd 100644 --- a/ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql +++ b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.graphql @@ -1 +1,2 @@ scalar OpenAPIMediaType +scalar OpenAPIAuthType \ No newline at end of file diff --git a/ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs index 8ed2763e3..0a73484fe 100644 --- a/ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs +++ b/ai-verify-apigw/graphql/modules/scalars/openapiScalars.mjs @@ -30,4 +30,34 @@ export const OpenAPIMediaType = new GraphQLScalarType({ extensions: { code: 'BAD_USER_INPUT' }, }); }, +}); + +// Validation function for checking MediaTypes +function _authType(value) { + if (typeof value === 'string') { + switch (value) { + case "No Auth": + case "Bearer Token": + case "Basic Auth": + return value; + } + } + throw new GraphQLError('Provided value is not a valid authentication type', { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +export const OpenAPIAuthType = new GraphQLScalarType({ + name: 'OpenAPIAuthType', + description: 'OpenAPI Authentication Type', + serialize: _authType, + parseValue: _authType, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return new _authType(ast.value); + } + throw new GraphQLError('Provided value is not a valid authentication type', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + }, }); \ No newline at end of file From edf263f19a7b5ab0594a72b923106208882697e5 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 15:14:01 +0800 Subject: [PATCH 011/176] adjust required fields and use custom scaler OpenAPIAuthType --- .../graphql/modules/assets/modelapi.graphql | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index 13bf1c891..cc1c5f57e 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -14,7 +14,7 @@ enum OpenAPIAllTypes { object } -enum OpenAPIMethodType { +enum OpenAPIMethod { POST GET } @@ -34,7 +34,7 @@ enum OpenAPIPathParamsStyles { type OpenAPIPathParamsType { name: String! @constraint(minLength:1, maxLength:128) type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes! + itemType: OpenAPIPrimitiveTypes style: OpenAPIPathParamsStyles explode: Boolean } @@ -49,22 +49,22 @@ enum OpenAPIQueryParamsStyles { type QueryParamsType { name: String! @constraint(minLength:1, maxLength:128) type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes! + itemType: OpenAPIPrimitiveTypes style: OpenAPIQueryParamsStyles explode: Boolean } type OpenAPIParametersType { subpath: String @constraint(minLength:1, maxLength:2048) - pathParams: OpenAPIPathParamsType - queryParams: QueryParamsType + pathParams: [OpenAPIPathParamsType] + queryParams: [QueryParamsType] } type OpenAPIRequestBodyPropertyType { field: String! @constraint(minLength:1, maxLength:128) type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes! + itemType: OpenAPIPrimitiveTypes style: OpenAPIQueryParamsStyles explode: Boolean } @@ -72,8 +72,7 @@ type OpenAPIRequestBodyPropertyType { type OpenAPIRequestBodyType { mediaType: OpenAPIMediaType! - params: [String] @constraint(minItems: 1, maxItems:128) # array of param keys - properties: [OpenAPIRequestBodyPropertyType] + properties: [OpenAPIRequestBodyPropertyType]! } type OpenAPIResponseType { @@ -97,10 +96,10 @@ type ModelAPIRequestConfigType { } type ModelAPIType { - method: OpenAPIMethodType! + method: OpenAPIMethod! url: URL! - authType: String! @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") - authTypeConfig: JSON! + authType: OpenAPIAuthType! + authTypeConfig: JSON additionalHeaders: [OpenAPIAdditionalHeadersType], parameters: OpenAPIParametersType requestBody: OpenAPIRequestBodyType @@ -110,66 +109,65 @@ type ModelAPIType { input OpenAPIAdditionalHeadersInput { - name: String @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength:1, maxLength:128) type: OpenAPIPrimitiveTypes! value: JSON! } input OpenAPIPathParamsInput { - name: String @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength:1, maxLength:128) type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes! + itemType: OpenAPIPrimitiveTypes style: OpenAPIPathParamsStyles explode: Boolean } input QueryParamsInput { - name: String @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength:1, maxLength:128) type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes! + itemType: OpenAPIPrimitiveTypes style: OpenAPIQueryParamsStyles explode: Boolean } input OpenAPIParametersInput { - subpath: String @constraint(minLength:1, maxLength:2048) - pathParams: OpenAPIPathParamsInput - queryParams: QueryParamsInput + subpath: String! @constraint(minLength:1, maxLength:2048) + pathParams: [OpenAPIPathParamsInput] + queryParams: [QueryParamsInput] } input OpenAPIRequestBodyPropertyInput { - field: String @constraint(minLength:1, maxLength:128) + field: String! @constraint(minLength:1, maxLength:128) type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes! + itemType: OpenAPIPrimitiveTypes style: OpenAPIQueryParamsStyles explode: Boolean } input OpenAPIRequestBodyInput { - mediaType: OpenAPIMediaType - params: [String] @constraint(minItems: 1, maxItems:128) # array of param keys + mediaType: OpenAPIMediaType! properties: [OpenAPIRequestBodyPropertyInput] } input OpenAPIResponseInput { - statusCode: Int @constraint(min:200, max:299) + statusCode: Int! @constraint(min:200, max:299) mediaType: OpenAPIMediaType! - type: OpenAPIAllTypes + type: OpenAPIAllTypes! field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field } input ModelAPIRequestConfigInput { - rateLimit: Int @constraint(min:-1) # max number of requests per minute - batchStrategy: ModelAPIRequestConfigBatchStrategy + rateLimit: Int! @constraint(min:-1) # max number of requests per minute + batchStrategy: ModelAPIRequestConfigBatchStrategy! batchLimit: Int @constraint(min:-1) # max number of requests in each batch - maxConnections: Int @constraint(min:-1) # max number of concurrent connections to API server - requestTimeout: Int @constraint(min:1) # request connection timeout in ms + maxConnections: Int! @constraint(min:-1) # max number of concurrent connections to API server + requestTimeout: Int! @constraint(min:1) # request connection timeout in ms } input ModelAPIInput { - method: OpenAPIMethodType + method: OpenAPIMethod url: URL - authType: String @constraint(pattern: "^(No Auth|Bearer Token|Basic Auth)$") + authType: OpenAPIAuthType authTypeConfig: JSON additionalHeaders: [OpenAPIAdditionalHeadersInput] parameters: OpenAPIParametersInput From 07f6c89473a7edf569cad350ea225b13a11ef9c6 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 15:14:13 +0800 Subject: [PATCH 012/176] add mutation apis --- .../graphql/modules/assets/model.graphql | 49 ++-- .../graphql/modules/assets/model.mjs | 255 ++++++++++-------- 2 files changed, 173 insertions(+), 131 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.graphql b/ai-verify-apigw/graphql/modules/assets/model.graphql index 919aa896b..3aa2bf64b 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.graphql +++ b/ai-verify-apigw/graphql/modules/assets/model.graphql @@ -19,29 +19,35 @@ enum ModelFileStatusType { } type ModelFile { - id: ObjectID! - name: String! - type: ModelAccessType! - filename: String # if type is not API - filePath: String # if type is not API - modelAPI: ModelAPIType # if type is API - ctime: DateTime - description: String - status: ModelFileStatusType - size: String - modelType: ModelType - serializer: String - modelFormat: String - errorMessages: String - createdAt: DateTime - updatedAt: DateTime + id: ObjectID! + name: String! + type: ModelAccessType! + filename: String # if type is not API + filePath: String # if type is not API + modelAPI: ModelAPIType # if type is API + ctime: DateTime + description: String + status: ModelFileStatusType + size: String + modelType: ModelType + serializer: String + modelFormat: String + errorMessages: String + createdAt: DateTime + updatedAt: DateTime } input ModelFileInput { - name: String @constraint(minLength: 0, maxLength: 128) - description: String @constraint(minLength: 0, maxLength: 256) - modelType: ModelType - modelAPI: ModelAPIInput # for model API inputs + name: String @constraint(minLength: 0, maxLength: 128) + description: String @constraint(minLength: 0, maxLength: 256) + modelType: ModelType +} + +input ModelAPIInput { + name: String @constraint(minLength: 0, maxLength: 128) + description: String @constraint(minLength: 0, maxLength: 256) + modelType: ModelType + modelAPI: ModelAPIInput # for model API inputs } type Query { @@ -50,6 +56,9 @@ type Query { } type Mutation { + # create model api + createModelAPI(model: ModelAPIInput!): ModelFile + updateModelAPI(modelFileID: ObjectID!, model: ModelAPIInput!): ModelFile # delete files deleteModelFile(id: ObjectID!): ObjectID updateModel(modelFileID: ObjectID!, modelFile: ModelFileInput): ModelFile diff --git a/ai-verify-apigw/graphql/modules/assets/model.mjs b/ai-verify-apigw/graphql/modules/assets/model.mjs index b3abbc837..39ee4acec 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.mjs +++ b/ai-verify-apigw/graphql/modules/assets/model.mjs @@ -6,121 +6,154 @@ import pubsub from '#lib/apolloPubSub.mjs'; const resolvers = { - Query: { - /** - * Returns list of model files - * @returns Promise with ModelFile[] - */ - modelFiles: (parent) => { - return new Promise((resolve, reject) => { - ModelFileModel.find().then(docs => { - resolve(docs) - }).catch(err => { - reject(err); - }) - }) - }, + Query: { + /** + * Returns list of model files + * @returns Promise with ModelFile[] + */ + modelFiles: (parent) => { + return new Promise((resolve, reject) => { + ModelFileModel.find().then(docs => { + resolve(docs) + }).catch(err => { + reject(err); + }) + }) }, - Mutation: { - deleteModelFile: (parent, {id}) => { - console.debug("deleteModelFile", id); - return new Promise((resolve, reject) => { - ModelFileModel.findById(id).then(result => { - if (!result) - return reject("Invalid Model ID") - return result; - }).then(async model => { - const project = await ProjectModel.findOne({"modelAndDatasets.model":id}); - if (project) { - const error = new GraphQLError(`Unable to delete ${model.name} as it is used by project ${project.projectInfo.name}. `, { - extensions: { - code: 'FILE_IN_USE', - }, - }); - return reject(error) - } else { - return model; - } - }).then(model => { - if (model.filePath) { - var filePath = model.filePath; - } else { - return reject("model.filePath is empty") - } - if (fs.existsSync(filePath)) { - let stat = fs.statSync(filePath); - if (stat.isDirectory()) { - try { - console.log("Removing dir %s", filePath) - fs.rmSync(filePath, { - recursive: true, - force: true - }) - } catch (err) { - console.log("rm dir error", err); - } - } else { - console.log("Removing file %s", filePath) - fsPromises.unlink(filePath); - } - } - - return model; - }).then(() => { - ModelFileModel.findByIdAndDelete(id).then(result => { - if (!result) - return reject("Invalid Model ID") - resolve(id); - }) - }).catch(err => { - reject(err); - }) - }); - }, - updateModel: (parent, {modelFileID, modelFile}) => { - return new Promise((resolve, reject) => { - ModelFileModel.findOne({_id: modelFileID}).then(async doc => { - if (doc) { - if (modelFile.description != null) { - doc.description = modelFile.description; - } - if (modelFile.modelType != null) { - doc.modelType = modelFile.modelType; - } - if (modelFile.name != null && modelFile.name != "") { - //check if name is already taken - const existingFile = await ModelFileModel.findOne({name: modelFile.name}); - if (existingFile) { - if (existingFile.id != modelFileID) { - console.log("Another file with the same name already exists, unable to update name to: ", modelFile.name) - const error = new GraphQLError('Duplicate File', { - extensions: { - code: 'BAD_USER_INPUT', - }, - }); - reject(error) - } - } else { - doc.name = modelFile.name; - } - } - if (modelFile.status != null) { - doc.status = modelFile.status; - } - let updatedDoc = await doc.save(); - resolve(updatedDoc); - } else { - reject(`Invalid id ${modelFileID}`); - } - }) - }) - }, + }, + Mutation: { + createModelAPI: (parent, { model }) => { + console.log("createModelAPI", model); + if (!model.name || !model.modelType || !model.modelAPI || !model.modelAPI.response) { + return Promise.reject("Missing variable") + } + // project.status = "NoReport"; + return new Promise((resolve, reject) => { + const doc = { + ...model, + type: "API", + status: "Valid", + } + ModelFileModel.create(doc).then((doc) => { + // console.debug("doc", doc); + // doc.save().then(()) + resolve(doc); + }).catch(err => { + reject(err) + }) + }) }, - Subscription: { - validateModelStatusUpdated: { - subscribe: () => pubsub.asyncIterator('VALIDATE_MODEL_STATUS_UPDATED') + updateModelAPI: (parent, { modelFileID, model }) => { + console.log("updateModelAPI", modelFileID, model); + return new Promise(async (resolve, reject) => { + try { + const newdoc = await ModelFileModel.findOneAndUpdate({ _id: modelFileID, type:'API' }, model, { new:true }) + resolve(newdoc); + } catch (err) { + reject(err); } + }) + }, + deleteModelFile: (parent, { id }) => { + console.debug("deleteModelFile", id); + return new Promise((resolve, reject) => { + ModelFileModel.findById(id).then(result => { + if (!result) + return reject("Invalid Model ID") + return result; + }).then(async model => { + const project = await ProjectModel.findOne({ "modelAndDatasets.model": id }); + if (project) { + const error = new GraphQLError(`Unable to delete ${model.name} as it is used by project ${project.projectInfo.name}. `, { + extensions: { + code: 'FILE_IN_USE', + }, + }); + return reject(error) + } else { + return model; + } + }).then(model => { + if (model.type !== "API") { + if (model.filePath) { + var filePath = model.filePath; + } else { + return reject("model.filePath is empty") + } + if (fs.existsSync(filePath)) { + let stat = fs.statSync(filePath); + if (stat.isDirectory()) { + try { + console.log("Removing dir %s", filePath) + fs.rmSync(filePath, { + recursive: true, + force: true + }) + } catch (err) { + console.log("rm dir error", err); + } + } else { + console.log("Removing file %s", filePath) + fsPromises.unlink(filePath); + } + } + } + return model; + }).then(() => { + ModelFileModel.findByIdAndDelete(id).then(result => { + if (!result) + return reject("Invalid Model ID") + resolve(id); + }) + }).catch(err => { + reject(err); + }) + }); + }, + updateModel: (parent, { modelFileID, modelFile }) => { + return new Promise((resolve, reject) => { + ModelFileModel.findOne({ _id: modelFileID }).then(async doc => { + if (doc) { + if (modelFile.description != null) { + doc.description = modelFile.description; + } + if (modelFile.modelType != null) { + doc.modelType = modelFile.modelType; + } + if (modelFile.name != null && modelFile.name != "") { + //check if name is already taken + const existingFile = await ModelFileModel.findOne({ name: modelFile.name }); + if (existingFile) { + if (existingFile.id != modelFileID) { + console.log("Another file with the same name already exists, unable to update name to: ", modelFile.name) + const error = new GraphQLError('Duplicate File', { + extensions: { + code: 'BAD_USER_INPUT', + }, + }); + reject(error) + } + } else { + doc.name = modelFile.name; + } + } + if (modelFile.status != null) { + doc.status = modelFile.status; + } + let updatedDoc = await doc.save(); + resolve(updatedDoc); + } else { + reject(`Invalid id ${modelFileID}`); + } + }) + }) }, + }, + Subscription: { + validateModelStatusUpdated: { + subscribe: () => pubsub.asyncIterator('VALIDATE_MODEL_STATUS_UPDATED') + } + }, } export default resolvers; \ No newline at end of file From e50cc6e88b28a74092b15690b4b0241ea3e1fa07 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 27 Jun 2023 17:31:17 +0800 Subject: [PATCH 013/176] add API and methods to export openapi schema --- .../graphql/modules/assets/model.graphql | 2 + .../graphql/modules/assets/model.mjs | 15 ++ .../graphql/modules/assets/modelapi.graphql | 4 +- ai-verify-apigw/models/model.model.mjs | 212 +++++++++++++++++- 4 files changed, 222 insertions(+), 11 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.graphql b/ai-verify-apigw/graphql/modules/assets/model.graphql index 3aa2bf64b..9f2b6f908 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.graphql +++ b/ai-verify-apigw/graphql/modules/assets/model.graphql @@ -53,6 +53,8 @@ input ModelAPIInput { type Query { # return list of modelfiles modelFiles: [ModelFile] + # retrieve the openapi specs for API model + getOpenAPISpecFromModel (modelFileID: ObjectID!): JSON } type Mutation { diff --git a/ai-verify-apigw/graphql/modules/assets/model.mjs b/ai-verify-apigw/graphql/modules/assets/model.mjs index 39ee4acec..51bcca86d 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.mjs +++ b/ai-verify-apigw/graphql/modules/assets/model.mjs @@ -20,6 +20,21 @@ const resolvers = { }) }) }, + getOpenAPISpecFromModel: (parent, {modelFileID}) => { + return new Promise((resolve, reject) => { + ModelFileModel.findById(modelFileID).then(doc => { + if (!doc) + return reject("Invalid ID"); + if (doc.type !== "API") + return reject("Model is not of type API"); + const spec = doc.exportModelAPI(); + // console.log("spec", spec); + if (!spec) + return reject("Unable to generate spec"); + resolve(spec); + }) + }) + } }, Mutation: { createModelAPI: (parent, { model }) => { diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index cc1c5f57e..e6ad0732f 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -55,7 +55,6 @@ type QueryParamsType { } type OpenAPIParametersType { - subpath: String @constraint(minLength:1, maxLength:2048) pathParams: [OpenAPIPathParamsType] queryParams: [QueryParamsType] } @@ -98,6 +97,7 @@ type ModelAPIRequestConfigType { type ModelAPIType { method: OpenAPIMethod! url: URL! + urlParams: String authType: OpenAPIAuthType! authTypeConfig: JSON additionalHeaders: [OpenAPIAdditionalHeadersType], @@ -131,7 +131,6 @@ input QueryParamsInput { } input OpenAPIParametersInput { - subpath: String! @constraint(minLength:1, maxLength:2048) pathParams: [OpenAPIPathParamsInput] queryParams: [QueryParamsInput] } @@ -167,6 +166,7 @@ input ModelAPIRequestConfigInput { input ModelAPIInput { method: OpenAPIMethod url: URL + urlParams: String @constraint(maxLength: 2048) authType: OpenAPIAuthType authTypeConfig: JSON additionalHeaders: [OpenAPIAdditionalHeadersInput] diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 2f3562421..888af1345 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -1,4 +1,5 @@ import { Schema, model } from 'mongoose'; +import _ from 'lodash'; const PRIMITIVE_TYPES = ["string", "number", "integer", "boolean"]; const ALL_TYPES = [...PRIMITIVE_TYPES, "array", "object"]; @@ -10,7 +11,6 @@ const modelAPIAdditionalHeadersSchema = new Schema({ }) const modelAPIParametersSchema = new Schema({ - subpath: { type: String }, pathParams: [{ name: { type: String, required: true }, type: { type: String, required: true, enum: ALL_TYPES }, @@ -41,6 +41,7 @@ const modelAPIRequestBodySchema = new Schema({ const modelAPISchema = new Schema({ method: { type: String, required: true, enum: ["POST", "GET"] }, url: { type: String, required: true }, + urlParams: { type: String }, authType: { type: String, required: true, enum: ["No Auth", "Bearer Token", "Basic Auth"], default: "No Auth" }, authTypeConfig: { type: Object }, additionalHeaders: [modelAPIAdditionalHeadersSchema], @@ -79,15 +80,208 @@ const modelFileSchema = new Schema({ timestamps: { createdAt: true, updatedAt: true } }); -export const ModelFileModel = model('ModelFileModel', modelFileSchema); +// very simple URL regex match +const _url_pattern = /^(?https?:\/\/(?:[a-z0-9.@:])+)(?\/?[^?]+)(?\?\/?.+)?/i; +const _url_path_pattern = /\{([a-z0-9_\-\s]+)\}/ig; +modelFileSchema.methods.exportModelAPI = function() { + if (this.type !== "API") { + return null; + } + + const modelAPI = this.modelAPI; + // console.log("exportModelAPI", modelAPI) + let spec = { + "openapi": "3.0.3", + "info": { + "title": "API-Based Testing", + "version": "1.0.0", + } + } -// const modelSchema = new Schema({ -// name: { type: String, required: true }, -// description: { type: String, required: false }, -// mode: { type: String, enum: ["API","Upload"] }, -// algorithmType: { type: String, default: "valid", enum: ["pending","valid","invalid"] }, -// }); + // build the path + let pathObj = { + "parameters": [], + "responses": { + [modelAPI.response.statusCode]: { + "description": "successful operation", + "content": { + [modelAPI.response.mediaType]: { + "schema": { + "type": (modelAPI.response.mediaType==='text/plain')?"integer":"object" + } + } + } + } + } + }; + + const url = modelAPI.url + modelAPI.urlParams; + const url_match = url.match(_url_pattern); + // add servers + spec["servers"] = [{ + "url": url_match.groups.base, + }] + + // add auth if any + switch (modelAPI.authType) { + case 'Bearer Token': + spec["components"] = { + "securitySchemes": { + "myAuth": { + type: 'http', + scheme: 'bearer', + } + } + } + pathObj["security"] = [{ "myAuth": [] }] + break; + case 'Basic Auth': + spec["components"] = { + "securitySchemes": { + "myAuth": { + type: 'http', + scheme: 'basic', + } + } + } + pathObj["security"] = [{ "myAuth": [] }]; + break; + } + + // add additional headers if any + if (modelAPI.additionalHeaders && modelAPI.additionalHeaders.length > 0) { + for (let p of modelAPI.additionalHeaders) { + pathObj.parameters.push({ + "in": "header", + "name": p.name, + "schema": { + "type": p.type + } + }) + } + } + + // add path params if any + const path_match = url_match.groups.path.match(_url_path_pattern); + if (path_match && modelAPI.parameters && modelAPI.parameters.pathParams && modelAPI.parameters.pathParams.length > 0) { + // console.log("path_match", path_match); + for (let item of path_match) { + let attr = item.replaceAll(/[{}]/g,''); + const p = modelAPI.parameters.pathParams.find(p => p.name === attr); + if (!p) { + return null; + } + let pobj = { + "in": "path", + "name": p.name, + "required": true, + "schema": { + "type": p.type, + } + } + if (p.type === "array") { + pobj.schema["items"] = { + "type": p.itemType || "string" + } + } + else if (p.type === "object") { + pobj.schema["properties"] = { + "type": p.itemType || "string" + } + } + if (p.type === "array" || p.type === "object") { + pobj["style"] = p.style || "simple"; + pobj["explode"] = _.isBoolean(p.explode)?p.explode:false; + } + pathObj.parameters.push(pobj); + } + } + + // add query params if any + if (modelAPI.parameters && modelAPI.parameters.queryParams && modelAPI.parameters.queryParams.length > 0) { + // has query params + for (let p of modelAPI.parameters.queryParams) { + let pobj = { + "in": "query", + "name": p.name, + "required": true, + "schema": { + "type": p.type + } + } + if (p.type === "array") { + pobj.schema["items"] = { + "type": p.itemType || "string" + } + } + else if (p.type === "object") { + pobj.schema["properties"] = { + "type": p.itemType || "string" + } + } + if (p.type === "array" || p.type === "object") { + pobj["style"] = p.style || "form"; + pobj["explode"] = _.isBoolean(p.explode)?p.explode:true; + } + pathObj.parameters.push(pobj); + } + } + + // add request body if any + if (modelAPI.requestBody && modelAPI.requestBody.mediaType !== 'none') { + let required = []; + let properties = {}; + let encoding = {}; + for (let prop of modelAPI.requestBody.properties) { + required.push(prop.field); + properties[prop.field] = { + "type": prop.type, + } + if (prop.type === "array") { + properties[prop.field]["items"] = { + "type": prop.itemType || "string" + } + } + else if (prop.type === "object") { + properties[prop.field]["properties"] = { + "type": prop.itemType || "string" + } + } + if (prop.type === "array" || prop.type === "object") { + encoding[prop.field] = { + "style": prop.style || "form", + "explode": _.isBoolean(prop.explode)?prop.explode:true, + } + } + } + if (_.isEmpty(encoding)) + encoding = undefined; + pathObj["requestBody"] = { + "required": true, + "content": { + [modelAPI.requestBody.mediaType]: { + "schema": { + "type": "object", + required, + properties, + }, + ..._.isEmpty(encoding)?{}:{encoding}, + } + } + } + } + + spec["paths"] = { + [url_match.groups.path] : { + [modelAPI.method.toLowerCase()]: pathObj + } + } + // const json = JSON.parse(spec.getSpecAsJson()); + // console.log("spec", spec) + // console.log("config", config) + return spec; +}; -// api model schema? \ No newline at end of file +export const ModelFileModel = model('ModelFileModel', modelFileSchema); \ No newline at end of file From 18277e0249513eed236aadde43a5cf7ec66bcf77 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 30 Jun 2023 14:47:02 +0800 Subject: [PATCH 014/176] add validator for modelAPI and returns meaningful error messages on error --- .../graphql/modules/assets/model.mjs | 16 +++++++----- ai-verify-apigw/models/model.model.mjs | 26 ++++++++++++++++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.mjs b/ai-verify-apigw/graphql/modules/assets/model.mjs index 51bcca86d..53743f0a7 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.mjs +++ b/ai-verify-apigw/graphql/modules/assets/model.mjs @@ -27,18 +27,22 @@ const resolvers = { return reject("Invalid ID"); if (doc.type !== "API") return reject("Model is not of type API"); - const spec = doc.exportModelAPI(); - // console.log("spec", spec); - if (!spec) - return reject("Unable to generate spec"); - resolve(spec); + try { + const spec = doc.exportModelAPI(); + // console.log("spec", spec); + if (!spec) + return reject("Unable to generate spec"); + resolve(spec); + } catch (err) { + reject(err); + } }) }) } }, Mutation: { createModelAPI: (parent, { model }) => { - console.log("createModelAPI", model); + // console.log("createModelAPI", model); if (!model.name || !model.modelType || !model.modelAPI || !model.modelAPI.response) { return Promise.reject("Missing variable") } diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 888af1345..fd187e27b 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -67,7 +67,22 @@ const modelFileSchema = new Schema({ type: { type: String, required: true, default: "File", enum: ["File", "Folder", "Pipeline", "API"] }, filename: { type: String }, // for non-API type filePath: { type: String }, // for non-API type - modelAPI: { type: modelAPISchema }, // for API type + modelAPI: { type: modelAPISchema, validate: { + validator: function(api) { + return new Promise((resolve, reject) => { + try { + const spec = _exportModelAPI(api); + if (spec) + resolve(true); + else + reject(new Error('Invalid model API')) + } catch (err) { + reject(err); + } + }) + }, + message: props => `ModelAPI is invalid` + } }, // for API type ctime: { type: Date }, description: { type: String, required: false }, status: { type: String, default: "Pending", enum: ["Pending", "Valid", "Invalid", "Error", "Cancelled", "Temp"] }, @@ -86,10 +101,13 @@ const _url_path_pattern = /\{([a-z0-9_\-\s]+)\}/ig; modelFileSchema.methods.exportModelAPI = function() { if (this.type !== "API") { - return null; + throw new Error("Model is not of type API") } + return _exportModelAPI(this.modelAPI); +} - const modelAPI = this.modelAPI; +function _exportModelAPI(modelAPI) { + // const modelAPI = this.modelAPI; // console.log("exportModelAPI", modelAPI) let spec = { "openapi": "3.0.3", @@ -170,7 +188,7 @@ modelFileSchema.methods.exportModelAPI = function() { let attr = item.replaceAll(/[{}]/g,''); const p = modelAPI.parameters.pathParams.find(p => p.name === attr); if (!p) { - return null; + throw new Error(`Path parameter {${attr}} not defined`); } let pobj = { "in": "path", From 34e86079d5d84be39f334b2d1f38a02b38bb9024 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 30 Jun 2023 17:33:45 +0800 Subject: [PATCH 015/176] add maxItems for array properties --- .../graphql/modules/assets/modelapi.graphql | 57 +-- ai-verify-apigw/models/model.model.mjs | 422 +++++++++++------- 2 files changed, 279 insertions(+), 200 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index e6ad0732f..46d47b845 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -1,13 +1,13 @@ enum OpenAPIPrimitiveTypes { string - number + Int integer boolean } enum OpenAPIAllTypes { string - number + Int integer boolean array @@ -20,7 +20,7 @@ enum OpenAPIMethod { } type OpenAPIAdditionalHeadersType { - name: String! @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIPrimitiveTypes! value: JSON! } @@ -32,9 +32,10 @@ enum OpenAPIPathParamsStyles { } type OpenAPIPathParamsType { - name: String! @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIAllTypes! itemType: OpenAPIPrimitiveTypes + maxItems: Int # max array items if itemType == 'array' style: OpenAPIPathParamsStyles explode: Boolean } @@ -47,9 +48,10 @@ enum OpenAPIQueryParamsStyles { } type QueryParamsType { - name: String! @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIAllTypes! itemType: OpenAPIPrimitiveTypes + maxItems: Int # max array items if itemType == 'array' style: OpenAPIQueryParamsStyles explode: Boolean } @@ -59,26 +61,25 @@ type OpenAPIParametersType { queryParams: [QueryParamsType] } - type OpenAPIRequestBodyPropertyType { - field: String! @constraint(minLength:1, maxLength:128) + field: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIAllTypes! itemType: OpenAPIPrimitiveTypes + maxItems: Int # max array items if itemType == 'array' style: OpenAPIQueryParamsStyles explode: Boolean } - type OpenAPIRequestBodyType { mediaType: OpenAPIMediaType! properties: [OpenAPIRequestBodyPropertyType]! } type OpenAPIResponseType { - statusCode: Int! @constraint(min:200, max:299) + statusCode: Int! @constraint(min: 200, max: 299) mediaType: OpenAPIMediaType! type: OpenAPIAllTypes - field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + field: String @constraint(minLength: 1, maxLength: 128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field } enum ModelAPIRequestConfigBatchStrategy { @@ -87,11 +88,11 @@ enum ModelAPIRequestConfigBatchStrategy { } type ModelAPIRequestConfigType { - rateLimit: Int! @constraint(min:-1) # max number of requests per minute + rateLimit: Int! @constraint(min: -1) # max Int of requests per minute batchStrategy: ModelAPIRequestConfigBatchStrategy! - batchLimit: Int @constraint(min:-1) # max number of requests in each batch - maxConnections: Int! @constraint(min:-1) # max number of concurrent connections to API server - requestTimeout: Int! @constraint(min:1) # request connection timeout in ms + batchLimit: Int @constraint(min: -1) # max Int of requests in each batch + maxConnections: Int! @constraint(min: -1) # max Int of concurrent connections to API server + requestTimeout: Int! @constraint(min: 1) # request connection timeout in ms } type ModelAPIType { @@ -100,32 +101,33 @@ type ModelAPIType { urlParams: String authType: OpenAPIAuthType! authTypeConfig: JSON - additionalHeaders: [OpenAPIAdditionalHeadersType], + additionalHeaders: [OpenAPIAdditionalHeadersType] parameters: OpenAPIParametersType requestBody: OpenAPIRequestBodyType response: OpenAPIResponseType! requestConfig: ModelAPIRequestConfigType! } - input OpenAPIAdditionalHeadersInput { - name: String! @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIPrimitiveTypes! value: JSON! } input OpenAPIPathParamsInput { - name: String! @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIAllTypes! itemType: OpenAPIPrimitiveTypes + maxItems: Int # max array items if itemType == 'array' style: OpenAPIPathParamsStyles explode: Boolean } input QueryParamsInput { - name: String! @constraint(minLength:1, maxLength:128) + name: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIAllTypes! itemType: OpenAPIPrimitiveTypes + maxItems: Int # max array items if itemType == 'array' style: OpenAPIQueryParamsStyles explode: Boolean } @@ -136,9 +138,10 @@ input OpenAPIParametersInput { } input OpenAPIRequestBodyPropertyInput { - field: String! @constraint(minLength:1, maxLength:128) + field: String! @constraint(minLength: 1, maxLength: 128) type: OpenAPIAllTypes! itemType: OpenAPIPrimitiveTypes + maxItems: Int # max array items if itemType == 'array' style: OpenAPIQueryParamsStyles explode: Boolean } @@ -149,18 +152,18 @@ input OpenAPIRequestBodyInput { } input OpenAPIResponseInput { - statusCode: Int! @constraint(min:200, max:299) + statusCode: Int! @constraint(min: 200, max: 299) mediaType: OpenAPIMediaType! type: OpenAPIAllTypes! - field: String @constraint(minLength:1, maxLength:128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field + field: String @constraint(minLength: 1, maxLength: 128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field } input ModelAPIRequestConfigInput { - rateLimit: Int! @constraint(min:-1) # max number of requests per minute + rateLimit: Int! @constraint(min: -1) # max Int of requests per minute batchStrategy: ModelAPIRequestConfigBatchStrategy! - batchLimit: Int @constraint(min:-1) # max number of requests in each batch - maxConnections: Int! @constraint(min:-1) # max number of concurrent connections to API server - requestTimeout: Int! @constraint(min:1) # request connection timeout in ms + batchLimit: Int @constraint(min: -1) # max Int of requests in each batch + maxConnections: Int! @constraint(min: -1) # max Int of concurrent connections to API server + requestTimeout: Int! @constraint(min: 1) # request connection timeout in ms } input ModelAPIInput { @@ -174,4 +177,4 @@ input ModelAPIInput { requestBody: OpenAPIRequestBodyInput response: OpenAPIResponseInput requestConfig: ModelAPIRequestConfigInput -} \ No newline at end of file +} diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index fd187e27b..76e80cdd5 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -1,5 +1,5 @@ -import { Schema, model } from 'mongoose'; -import _ from 'lodash'; +import { Schema, model } from "mongoose"; +import _ from "lodash"; const PRIMITIVE_TYPES = ["string", "number", "integer", "boolean"]; const ALL_TYPES = [...PRIMITIVE_TYPES, "array", "object"]; @@ -8,162 +8,223 @@ const modelAPIAdditionalHeadersSchema = new Schema({ name: { type: String, required: true }, type: { type: String, required: true, enum: PRIMITIVE_TYPES }, value: { type: Object, required: true }, -}) +}); const modelAPIParametersSchema = new Schema({ - pathParams: [{ - name: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - style: { type: String, enum: ["simple", "label", "matrix"], default: "simple" }, - explode: { type: Boolean, default: false } - }], - queryParams: [{ - name: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - style: { type: String, enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], default: "form" }, - explode: { type: Boolean, default: true } - }] -}) + pathParams: [ + { + name: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + maxItems: { type: Number }, // max array items if itemType == 'array' + style: { + type: String, + enum: ["simple", "label", "matrix"], + default: "simple", + }, + explode: { type: Boolean, default: false }, + }, + ], + queryParams: [ + { + name: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + maxItems: { type: Number }, // max array items if itemType == 'array' + style: { + type: String, + enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], + default: "form", + }, + explode: { type: Boolean, default: true }, + }, + ], +}); const modelAPIRequestBodySchema = new Schema({ - mediaType: { type: String, required: true, enum: ["none", "multipart/form-data", "application/x-www-form-urlencoded"] }, - properties: [{ - field: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - style: { type: String, enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], default: "form" }, - explode: { type: Boolean, default: true } - }] -}) + mediaType: { + type: String, + required: true, + enum: ["none", "multipart/form-data", "application/x-www-form-urlencoded"], + }, + properties: [ + { + field: { type: String, required: true }, + type: { type: String, required: true, enum: ALL_TYPES }, + itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" + maxItems: { type: Number }, // max array items if itemType == 'array' + style: { + type: String, + enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], + default: "form", + }, + explode: { type: Boolean, default: true }, + }, + ], +}); const modelAPISchema = new Schema({ method: { type: String, required: true, enum: ["POST", "GET"] }, url: { type: String, required: true }, urlParams: { type: String }, - authType: { type: String, required: true, enum: ["No Auth", "Bearer Token", "Basic Auth"], default: "No Auth" }, + authType: { + type: String, + required: true, + enum: ["No Auth", "Bearer Token", "Basic Auth"], + default: "No Auth", + }, authTypeConfig: { type: Object }, additionalHeaders: [modelAPIAdditionalHeadersSchema], parameters: modelAPIParametersSchema, requestBody: modelAPIRequestBodySchema, response: { statusCode: { type: Number, required: true, default: 200 }, - mediaType: { type: String, required: true, enum: ["text/plain", "application/json"] }, + mediaType: { + type: String, + required: true, + enum: ["text/plain", "application/json"], + }, type: { type: String, enum: ALL_TYPES, default: "integer" }, field: { type: String }, // for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field }, requestConfig: { rateLimit: { type: Number, required: true }, - batchStrategy: { type: String, required: true, enum: ["none","multipart"] }, + batchStrategy: { + type: String, + required: true, + enum: ["none", "multipart"], + }, batchLimit: { type: Number }, maxConnections: { type: Number, required: true }, requestTimeout: { type: Number, required: true }, - } -}) + }, +}); -const modelFileSchema = new Schema({ - name: { type: String, required: true }, - type: { type: String, required: true, default: "File", enum: ["File", "Folder", "Pipeline", "API"] }, - filename: { type: String }, // for non-API type - filePath: { type: String }, // for non-API type - modelAPI: { type: modelAPISchema, validate: { - validator: function(api) { - return new Promise((resolve, reject) => { - try { - const spec = _exportModelAPI(api); - if (spec) - resolve(true); - else - reject(new Error('Invalid model API')) - } catch (err) { - reject(err); - } - }) +const modelFileSchema = new Schema( + { + name: { type: String, required: true }, + type: { + type: String, + required: true, + default: "File", + enum: ["File", "Folder", "Pipeline", "API"], }, - message: props => `ModelAPI is invalid` - } }, // for API type - ctime: { type: Date }, - description: { type: String, required: false }, - status: { type: String, default: "Pending", enum: ["Pending", "Valid", "Invalid", "Error", "Cancelled", "Temp"] }, - size: { type: String }, - modelType: { type: String, required: false, enum: ["Classification", "Regression"] }, - serializer: { type: String }, - modelFormat: { type: String }, - errorMessages: { type: String }, -}, { - timestamps: { createdAt: true, updatedAt: true } -}); + filename: { type: String }, // for non-API type + filePath: { type: String }, // for non-API type + modelAPI: { + type: modelAPISchema, + validate: { + validator: function (api) { + return new Promise((resolve, reject) => { + try { + const spec = _exportModelAPI(api); + if (spec) resolve(true); + else reject(new Error("Invalid model API")); + } catch (err) { + reject(err); + } + }); + }, + message: (props) => `ModelAPI is invalid`, + }, + }, // for API type + ctime: { type: Date }, + description: { type: String, required: false }, + status: { + type: String, + default: "Pending", + enum: ["Pending", "Valid", "Invalid", "Error", "Cancelled", "Temp"], + }, + size: { type: String }, + modelType: { + type: String, + required: false, + enum: ["Classification", "Regression"], + }, + serializer: { type: String }, + modelFormat: { type: String }, + errorMessages: { type: String }, + }, + { + timestamps: { createdAt: true, updatedAt: true }, + } +); // very simple URL regex match -const _url_pattern = /^(?https?:\/\/(?:[a-z0-9.@:])+)(?\/?[^?]+)(?\?\/?.+)?/i; -const _url_path_pattern = /\{([a-z0-9_\-\s]+)\}/ig; +const _url_pattern = + /^(?https?:\/\/(?:[a-z0-9.@:])+)(?\/?[^?]+)(?\?\/?.+)?/i; +const _url_path_pattern = /\{([a-z0-9_\-\s]+)\}/gi; -modelFileSchema.methods.exportModelAPI = function() { +modelFileSchema.methods.exportModelAPI = function () { if (this.type !== "API") { - throw new Error("Model is not of type API") + throw new Error("Model is not of type API"); } return _exportModelAPI(this.modelAPI); -} +}; function _exportModelAPI(modelAPI) { // const modelAPI = this.modelAPI; // console.log("exportModelAPI", modelAPI) let spec = { - "openapi": "3.0.3", - "info": { - "title": "API-Based Testing", - "version": "1.0.0", - } - } + openapi: "3.0.3", + info: { + title: "API-Based Testing", + version: "1.0.0", + }, + }; // build the path let pathObj = { - "parameters": [], - "responses": { + parameters: [], + responses: { [modelAPI.response.statusCode]: { - "description": "successful operation", - "content": { + description: "successful operation", + content: { [modelAPI.response.mediaType]: { - "schema": { - "type": (modelAPI.response.mediaType==='text/plain')?"integer":"object" - } - } - } - } - } + schema: { + type: + modelAPI.response.mediaType === "text/plain" + ? "integer" + : "object", + }, + }, + }, + }, + }, }; const url = modelAPI.url + modelAPI.urlParams; const url_match = url.match(_url_pattern); // add servers - spec["servers"] = [{ - "url": url_match.groups.base, - }] + spec["servers"] = [ + { + url: url_match.groups.base, + }, + ]; // add auth if any switch (modelAPI.authType) { - case 'Bearer Token': + case "Bearer Token": spec["components"] = { - "securitySchemes": { - "myAuth": { - type: 'http', - scheme: 'bearer', - } - } - } - pathObj["security"] = [{ "myAuth": [] }] + securitySchemes: { + myAuth: { + type: "http", + scheme: "bearer", + }, + }, + }; + pathObj["security"] = [{ myAuth: [] }]; break; - case 'Basic Auth': + case "Basic Auth": spec["components"] = { - "securitySchemes": { - "myAuth": { - type: 'http', - scheme: 'basic', - } - } - } - pathObj["security"] = [{ "myAuth": [] }]; + securitySchemes: { + myAuth: { + type: "http", + scheme: "basic", + }, + }, + }; + pathObj["security"] = [{ myAuth: [] }]; break; } @@ -171,135 +232,150 @@ function _exportModelAPI(modelAPI) { if (modelAPI.additionalHeaders && modelAPI.additionalHeaders.length > 0) { for (let p of modelAPI.additionalHeaders) { pathObj.parameters.push({ - "in": "header", - "name": p.name, - "schema": { - "type": p.type - } - }) + in: "header", + name: p.name, + required: true, + schema: { + type: p.type, + enum: [p.value], + }, + }); } } // add path params if any const path_match = url_match.groups.path.match(_url_path_pattern); - if (path_match && modelAPI.parameters && modelAPI.parameters.pathParams && modelAPI.parameters.pathParams.length > 0) { + if ( + path_match && + modelAPI.parameters && + modelAPI.parameters.pathParams && + modelAPI.parameters.pathParams.length > 0 + ) { // console.log("path_match", path_match); for (let item of path_match) { - let attr = item.replaceAll(/[{}]/g,''); - const p = modelAPI.parameters.pathParams.find(p => p.name === attr); + let attr = item.replaceAll(/[{}]/g, ""); + const p = modelAPI.parameters.pathParams.find((p) => p.name === attr); if (!p) { throw new Error(`Path parameter {${attr}} not defined`); } let pobj = { - "in": "path", - "name": p.name, - "required": true, - "schema": { - "type": p.type, - } - } + in: "path", + name: p.name, + required: true, + schema: { + type: p.type, + }, + }; if (p.type === "array") { - pobj.schema["items"] = { - "type": p.itemType || "string" + if (p.maxItems) { + pobj.schema.maxItems = p.maxItems; } - } - else if (p.type === "object") { + pobj.schema["items"] = { + type: p.itemType || "string", + }; + } else if (p.type === "object") { pobj.schema["properties"] = { - "type": p.itemType || "string" - } + type: p.itemType || "string", + }; } if (p.type === "array" || p.type === "object") { pobj["style"] = p.style || "simple"; - pobj["explode"] = _.isBoolean(p.explode)?p.explode:false; + pobj["explode"] = _.isBoolean(p.explode) ? p.explode : false; } pathObj.parameters.push(pobj); } } // add query params if any - if (modelAPI.parameters && modelAPI.parameters.queryParams && modelAPI.parameters.queryParams.length > 0) { + if ( + modelAPI.parameters && + modelAPI.parameters.queryParams && + modelAPI.parameters.queryParams.length > 0 + ) { // has query params for (let p of modelAPI.parameters.queryParams) { let pobj = { - "in": "query", - "name": p.name, - "required": true, - "schema": { - "type": p.type - } - } + in: "query", + name: p.name, + required: true, + schema: { + type: p.type, + }, + }; if (p.type === "array") { - pobj.schema["items"] = { - "type": p.itemType || "string" + if (p.maxItems) { + pobj.schema.maxItems = p.maxItems; } - } - else if (p.type === "object") { + pobj.schema["items"] = { + type: p.itemType || "string", + }; + } else if (p.type === "object") { pobj.schema["properties"] = { - "type": p.itemType || "string" - } + type: p.itemType || "string", + }; } if (p.type === "array" || p.type === "object") { pobj["style"] = p.style || "form"; - pobj["explode"] = _.isBoolean(p.explode)?p.explode:true; + pobj["explode"] = _.isBoolean(p.explode) ? p.explode : true; } pathObj.parameters.push(pobj); } } // add request body if any - if (modelAPI.requestBody && modelAPI.requestBody.mediaType !== 'none') { + if (modelAPI.requestBody && modelAPI.requestBody.mediaType !== "none") { let required = []; let properties = {}; let encoding = {}; for (let prop of modelAPI.requestBody.properties) { required.push(prop.field); properties[prop.field] = { - "type": prop.type, - } + type: prop.type, + }; if (prop.type === "array") { - properties[prop.field]["items"] = { - "type": prop.itemType || "string" + if (prop.maxItems) { + properties[prop.field]["maxItems"] = prop.maxItems; } - } - else if (prop.type === "object") { + properties[prop.field]["items"] = { + type: prop.itemType || "string", + }; + } else if (prop.type === "object") { properties[prop.field]["properties"] = { - "type": prop.itemType || "string" - } + type: prop.itemType || "string", + }; } if (prop.type === "array" || prop.type === "object") { encoding[prop.field] = { - "style": prop.style || "form", - "explode": _.isBoolean(prop.explode)?prop.explode:true, - } + style: prop.style || "form", + explode: _.isBoolean(prop.explode) ? prop.explode : true, + }; } } - if (_.isEmpty(encoding)) - encoding = undefined; + if (_.isEmpty(encoding)) encoding = undefined; pathObj["requestBody"] = { - "required": true, - "content": { + required: true, + content: { [modelAPI.requestBody.mediaType]: { - "schema": { - "type": "object", + schema: { + type: "object", required, properties, }, - ..._.isEmpty(encoding)?{}:{encoding}, - } - } - } + ...(_.isEmpty(encoding) ? {} : { encoding }), + }, + }, + }; } spec["paths"] = { - [url_match.groups.path] : { - [modelAPI.method.toLowerCase()]: pathObj - } - } + [url_match.groups.path]: { + [modelAPI.method.toLowerCase()]: pathObj, + }, + }; // const json = JSON.parse(spec.getSpecAsJson()); // console.log("spec", spec) // console.log("config", config) return spec; -}; - +} -export const ModelFileModel = model('ModelFileModel', modelFileSchema); \ No newline at end of file +export const ModelFileModel = model("ModelFileModel", modelFileSchema); From fd5d4b8650f2217f79790257b9b0639bc4374d12 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 3 Jul 2023 10:50:14 +0800 Subject: [PATCH 016/176] prettier --- .../graphql/modules/assets/model.mjs | 214 ++++++++++-------- 1 file changed, 118 insertions(+), 96 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.mjs b/ai-verify-apigw/graphql/modules/assets/model.mjs index 53743f0a7..ae4f55d59 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.mjs +++ b/ai-verify-apigw/graphql/modules/assets/model.mjs @@ -1,9 +1,8 @@ -import { ModelFileModel, ProjectModel } from '#models'; -import { GraphQLError } from 'graphql'; -import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; -import pubsub from '#lib/apolloPubSub.mjs'; - +import { ModelFileModel, ProjectModel } from "#models"; +import { GraphQLError } from "graphql"; +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import pubsub from "#lib/apolloPubSub.mjs"; const resolvers = { Query: { @@ -13,38 +12,42 @@ const resolvers = { */ modelFiles: (parent) => { return new Promise((resolve, reject) => { - ModelFileModel.find().then(docs => { - resolve(docs) - }).catch(err => { - reject(err); - }) - }) + ModelFileModel.find() + .then((docs) => { + resolve(docs); + }) + .catch((err) => { + reject(err); + }); + }); }, - getOpenAPISpecFromModel: (parent, {modelFileID}) => { + getOpenAPISpecFromModel: (parent, { modelFileID }) => { return new Promise((resolve, reject) => { - ModelFileModel.findById(modelFileID).then(doc => { - if (!doc) - return reject("Invalid ID"); - if (doc.type !== "API") - return reject("Model is not of type API"); + ModelFileModel.findById(modelFileID).then((doc) => { + if (!doc) return reject("Invalid ID"); + if (doc.type !== "API") return reject("Model is not of type API"); try { const spec = doc.exportModelAPI(); - // console.log("spec", spec); - if (!spec) - return reject("Unable to generate spec"); - resolve(spec); + // console.log("spec", spec); + if (!spec) return reject("Unable to generate spec"); + resolve(spec); } catch (err) { reject(err); } - }) - }) - } + }); + }); + }, }, Mutation: { createModelAPI: (parent, { model }) => { // console.log("createModelAPI", model); - if (!model.name || !model.modelType || !model.modelAPI || !model.modelAPI.response) { - return Promise.reject("Missing variable") + if ( + !model.name || + !model.modelType || + !model.modelAPI || + !model.modelAPI.response + ) { + return Promise.reject("Missing variable"); } // project.status = "NoReport"; return new Promise((resolve, reject) => { @@ -52,86 +55,100 @@ const resolvers = { ...model, type: "API", status: "Valid", - } - ModelFileModel.create(doc).then((doc) => { - // console.debug("doc", doc); - // doc.save().then(()) - resolve(doc); - }).catch(err => { - reject(err) - }) - }) + }; + ModelFileModel.create(doc) + .then((doc) => { + // console.debug("doc", doc); + // doc.save().then(()) + resolve(doc); + }) + .catch((err) => { + reject(err); + }); + }); }, updateModelAPI: (parent, { modelFileID, model }) => { console.log("updateModelAPI", modelFileID, model); return new Promise(async (resolve, reject) => { try { - const newdoc = await ModelFileModel.findOneAndUpdate({ _id: modelFileID, type:'API' }, model, { new:true }) - resolve(newdoc); + const newdoc = await ModelFileModel.findOneAndUpdate( + { _id: modelFileID, type: "API" }, + model, + { new: true } + ); + resolve(newdoc); } catch (err) { reject(err); } - }) + }); }, deleteModelFile: (parent, { id }) => { console.debug("deleteModelFile", id); return new Promise((resolve, reject) => { - ModelFileModel.findById(id).then(result => { - if (!result) - return reject("Invalid Model ID") - return result; - }).then(async model => { - const project = await ProjectModel.findOne({ "modelAndDatasets.model": id }); - if (project) { - const error = new GraphQLError(`Unable to delete ${model.name} as it is used by project ${project.projectInfo.name}. `, { - extensions: { - code: 'FILE_IN_USE', - }, + ModelFileModel.findById(id) + .then((result) => { + if (!result) return reject("Invalid Model ID"); + return result; + }) + .then(async (model) => { + const project = await ProjectModel.findOne({ + "modelAndDatasets.model": id, }); - return reject(error) - } else { - return model; - } - }).then(model => { - if (model.type !== "API") { - if (model.filePath) { - var filePath = model.filePath; + if (project) { + const error = new GraphQLError( + `Unable to delete ${model.name} as it is used by project ${project.projectInfo.name}. `, + { + extensions: { + code: "FILE_IN_USE", + }, + } + ); + return reject(error); } else { - return reject("model.filePath is empty") + return model; } - if (fs.existsSync(filePath)) { - let stat = fs.statSync(filePath); - if (stat.isDirectory()) { - try { - console.log("Removing dir %s", filePath) - fs.rmSync(filePath, { - recursive: true, - force: true - }) - } catch (err) { - console.log("rm dir error", err); - } + }) + .then((model) => { + if (model.type !== "API") { + if (model.filePath) { + var filePath = model.filePath; } else { - console.log("Removing file %s", filePath) - fsPromises.unlink(filePath); + return reject("model.filePath is empty"); } - } - } - return model; - }).then(() => { - ModelFileModel.findByIdAndDelete(id).then(result => { - if (!result) - return reject("Invalid Model ID") - resolve(id); + if (fs.existsSync(filePath)) { + let stat = fs.statSync(filePath); + if (stat.isDirectory()) { + try { + console.log("Removing dir %s", filePath); + fs.rmSync(filePath, { + recursive: true, + force: true, + }); + } catch (err) { + console.log("rm dir error", err); + } + } else { + console.log("Removing file %s", filePath); + fsPromises.unlink(filePath); + } + } + } + return model; }) - }).catch(err => { - reject(err); - }) + .then(() => { + ModelFileModel.findByIdAndDelete(id).then((result) => { + if (!result) return reject("Invalid Model ID"); + resolve(id); + }); + }) + .catch((err) => { + reject(err); + }); }); }, updateModel: (parent, { modelFileID, modelFile }) => { return new Promise((resolve, reject) => { - ModelFileModel.findOne({ _id: modelFileID }).then(async doc => { + ModelFileModel.findOne({ _id: modelFileID }).then(async (doc) => { if (doc) { if (modelFile.description != null) { doc.description = modelFile.description; @@ -141,16 +158,21 @@ const resolvers = { } if (modelFile.name != null && modelFile.name != "") { //check if name is already taken - const existingFile = await ModelFileModel.findOne({ name: modelFile.name }); + const existingFile = await ModelFileModel.findOne({ + name: modelFile.name, + }); if (existingFile) { if (existingFile.id != modelFileID) { - console.log("Another file with the same name already exists, unable to update name to: ", modelFile.name) - const error = new GraphQLError('Duplicate File', { + console.log( + "Another file with the same name already exists, unable to update name to: ", + modelFile.name + ); + const error = new GraphQLError("Duplicate File", { extensions: { - code: 'BAD_USER_INPUT', + code: "BAD_USER_INPUT", }, }); - reject(error) + reject(error); } } else { doc.name = modelFile.name; @@ -164,15 +186,15 @@ const resolvers = { } else { reject(`Invalid id ${modelFileID}`); } - }) - }) + }); + }); }, }, Subscription: { validateModelStatusUpdated: { - subscribe: () => pubsub.asyncIterator('VALIDATE_MODEL_STATUS_UPDATED') - } + subscribe: () => pubsub.asyncIterator("VALIDATE_MODEL_STATUS_UPDATED"), + }, }, -} +}; -export default resolvers; \ No newline at end of file +export default resolvers; From 76da6113dd5f27c8c5b24496e71f0a5322b38d9d Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 3 Jul 2023 12:47:40 +0800 Subject: [PATCH 017/176] prettier --- ai-verify-apigw/models/project.model.mjs | 195 +++++++++++++---------- 1 file changed, 108 insertions(+), 87 deletions(-) diff --git a/ai-verify-apigw/models/project.model.mjs b/ai-verify-apigw/models/project.model.mjs index e471daa5c..9e0e75d1b 100644 --- a/ai-verify-apigw/models/project.model.mjs +++ b/ai-verify-apigw/models/project.model.mjs @@ -2,123 +2,144 @@ * Project mongoose models */ -import { Schema, model } from 'mongoose'; +import { Schema, model } from "mongoose"; // import { componentDependencySchema } from './plugin.model.mjs'; -import { REPORT_DIRNAME, getReportFilename } from '../lib/report.mjs'; -import fs from 'node:fs'; -import path from 'node:path'; -import { Validator } from 'jsonschema'; +import { REPORT_DIRNAME, getReportFilename } from "../lib/report.mjs"; +import fs from "node:fs"; +import path from "node:path"; +import { Validator } from "jsonschema"; const validator = new Validator(); -import { getAlgorithmInputSchema } from '#lib/plugin.mjs'; +import { getAlgorithmInputSchema } from "#lib/plugin.mjs"; const pageSchema = new Schema({ - layouts: [Object], // dashboard grid layout - reportWidgets: [{ - widgetGID: { type: String }, - key: { type: String }, // layout item key - layoutItemProperties: { - justifyContent: { type: String, default: "left" }, - alignItems: { type: String, default: "top" }, - textAlign: { type: String, default: "left" }, - color: { type: String }, - bgcolor: { type: String }, - }, - properties: { type: Object }, - }], -}) + layouts: [Object], // dashboard grid layout + reportWidgets: [ + { + widgetGID: { type: String }, + key: { type: String }, // layout item key + layoutItemProperties: { + justifyContent: { type: String, default: "left" }, + alignItems: { type: String, default: "top" }, + textAlign: { type: String, default: "left" }, + color: { type: String }, + bgcolor: { type: String }, + }, + properties: { type: Object }, + }, + ], +}); export const componentDependencySchema = new Schema({ - type: { type: String, required: true, enum: ["Algorithm", "InputBlock"] }, - gid: { type: String, required: true }, - version: { type: String, required: true }, -}) + type: { type: String, required: true, enum: ["Algorithm", "InputBlock"] }, + gid: { type: String, required: true }, + version: { type: String, required: true }, +}); -const projectTemplateSchema = new Schema({ +const projectTemplateSchema = new Schema( + { fromPlugin: { - type: Boolean, - default: false + type: Boolean, + default: false, }, - projectInfo: { - name: { type: String }, - description: { type: String }, - reportTitle: { type: String }, - company: { type: String }, + projectInfo: { + name: { type: String }, + description: { type: String }, + reportTitle: { type: String }, + company: { type: String }, }, pages: [pageSchema], dependencies: [componentDependencySchema], - inputBlockGIDs: [{ type: String }], - globalVars: [{ + inputBlockGIDs: [{ type: String }], + globalVars: [ + { key: { type: String }, - value: { type: String }, - }], -}, { + value: { type: String }, + }, + ], + }, + { timestamps: { - createdAt: true, - updatedAt: true - } -}); + createdAt: true, + updatedAt: true, + }, + } +); -projectTemplateSchema.index({ 'projectInfo.name': 'text', 'projectInfo.description': 'text'}); +projectTemplateSchema.index({ + "projectInfo.name": "text", + "projectInfo.description": "text", +}); -export const ProjectTemplateModel = model('ProjectTemplateModel', projectTemplateSchema); +export const ProjectTemplateModel = model( + "ProjectTemplateModel", + projectTemplateSchema +); -export const projectSchema = new Schema({ - template: { type: Schema.Types.ObjectId, ref: 'ProjectTemplateModel' }, // reference to template used +export const projectSchema = new Schema( + { + template: { type: Schema.Types.ObjectId, ref: "ProjectTemplateModel" }, // reference to template used inputBlockData: { type: Object, default: {} }, - testInformationData: [{ + testInformationData: [ + { algorithmGID: { type: String }, // isTestArgumentsValid: { type: Boolean }, - testArguments: { type: Object, default: {} }, - }], - modelAndDatasets: { - model: { type: Schema.Types.ObjectId, ref: 'ModelFileModel' }, - testDataset: { type: Schema.Types.ObjectId, ref: 'DatasetModel' }, - groundTruthDataset: { type: Schema.Types.ObjectId, ref: 'DatasetModel' }, - groundTruthColumn: { type: String } + testArguments: { type: Object, default: {} }, + }, + ], + modelAndDatasets: { + model: { type: Schema.Types.ObjectId, ref: "ModelFileModel" }, + testDataset: { type: Schema.Types.ObjectId, ref: "DatasetModel" }, + groundTruthDataset: { type: Schema.Types.ObjectId, ref: "DatasetModel" }, + groundTruthColumn: { type: String }, }, - report: { type: Schema.Types.ObjectId, ref: 'ReportModel' }, -}, { timestamps: { createdAt: true, updatedAt: true } }); + report: { type: Schema.Types.ObjectId, ref: "ReportModel" }, + }, + { timestamps: { createdAt: true, updatedAt: true } } +); projectSchema.statics.isTestArgumentsValid = async function (testInfo) { - const inputSchema = await getAlgorithmInputSchema(testInfo.algorithmGID); - let data = {} - if (testInfo.testArguments) { - data = testInfo.testArguments; - } - const res = validator.validate(data, inputSchema); - return res.valid; -} + const inputSchema = await getAlgorithmInputSchema(testInfo.algorithmGID); + let data = {}; + if (testInfo.testArguments) { + data = testInfo.testArguments; + } + const res = validator.validate(data, inputSchema); + return res.valid; +}; /** * Middeware to auto populate model and datasets */ -projectSchema.pre("find", function() { - this.populate("modelAndDatasets.model"); - this.populate("modelAndDatasets.testDataset"); - this.populate("modelAndDatasets.groundTruthDataset"); -}) +projectSchema.pre("find", function () { + this.populate("modelAndDatasets.model"); + this.populate("modelAndDatasets.testDataset"); + this.populate("modelAndDatasets.groundTruthDataset"); +}); -projectSchema.pre("findOne", function() { - this.populate("modelAndDatasets.model"); - this.populate("modelAndDatasets.testDataset"); - this.populate("modelAndDatasets.groundTruthDataset"); -}) +projectSchema.pre("findOne", function () { + this.populate("modelAndDatasets.model"); + this.populate("modelAndDatasets.testDataset"); + this.populate("modelAndDatasets.groundTruthDataset"); +}); /** * Middleware to cascade delete corresponding report record */ -projectSchema.pre("findOneAndDelete", async function() { - // import { ReportModel } from './report.model.mjs'; - const projectId = this.getQuery()._id; - const filename = getReportFilename(projectId); - const pdf_path = path.join(REPORT_DIRNAME, filename); - // console.log("pdf_path", pdf_path) - if (fs.existsSync(pdf_path)) { - console.log("rm") - fs.rmSync(pdf_path); - } - const ReportModel = model('ReportModel'); - await ReportModel.deleteMany({ project: projectId }); -}) +projectSchema.pre("findOneAndDelete", async function () { + // import { ReportModel } from './report.model.mjs'; + const projectId = this.getQuery()._id; + const filename = getReportFilename(projectId); + const pdf_path = path.join(REPORT_DIRNAME, filename); + // console.log("pdf_path", pdf_path) + if (fs.existsSync(pdf_path)) { + console.log("rm"); + fs.rmSync(pdf_path); + } + const ReportModel = model("ReportModel"); + await ReportModel.deleteMany({ project: projectId }); +}); -export const ProjectModel = ProjectTemplateModel.discriminator('ProjectModel', projectSchema); +export const ProjectModel = ProjectTemplateModel.discriminator( + "ProjectModel", + projectSchema +); From dee82a5590f8fe390c5513d1e6db9447d96115dc Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 3 Jul 2023 13:14:27 +0800 Subject: [PATCH 018/176] prettier --- .../graphql/modules/project/project.mjs | 947 +++++++++--------- 1 file changed, 492 insertions(+), 455 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/project/project.mjs b/ai-verify-apigw/graphql/modules/project/project.mjs index 60bf1eb7f..619488b91 100644 --- a/ai-verify-apigw/graphql/modules/project/project.mjs +++ b/ai-verify-apigw/graphql/modules/project/project.mjs @@ -1,465 +1,502 @@ -"use strict" +"use strict"; -import { withFilter } from 'graphql-subscriptions'; -import moment from 'moment'; +import { withFilter } from "graphql-subscriptions"; +import moment from "moment"; // import { Validator } from 'jsonschema'; // const validator = new Validator(); -import mongoose from 'mongoose'; -import { ProjectModel, ReportModel, ProjectTemplateModel } from '#models'; -import { getAlgorithm, getAlgorithmInputSchema } from '#lib/plugin.mjs'; -import { queueTests, cancelTestRun } from '#lib/testEngineQueue.mjs'; -import pubsub from '#lib/apolloPubSub.mjs'; -import { generateReport } from '#lib/report.mjs'; - +import mongoose from "mongoose"; +import { ProjectModel, ReportModel, ProjectTemplateModel } from "#models"; +import { getAlgorithm, getAlgorithmInputSchema } from "#lib/plugin.mjs"; +import { queueTests, cancelTestRun } from "#lib/testEngineQueue.mjs"; +import pubsub from "#lib/apolloPubSub.mjs"; +import { generateReport } from "#lib/report.mjs"; const resolvers = { - Query: { - /** - * Return list of projects - * @returns Promise with Project[] - */ - projects: (parent) => { - // console.debug("projects") - return new Promise((resolve, reject) => { - ProjectModel.find({ __t: 'ProjectModel' }).populate("report").populate("template").then(docs => { - // console.debug("docs", docs); - const results = docs.map( doc => { - if (doc.report) - doc.report.projectID = doc._id; - return doc; - }) - resolve(results) - }).catch(err => { - reject(err); - }) - }) - }, // projects - projectsByTextSearch: async (parent, {text}) => { - try { - const promisedDocs = ProjectModel.find({ $text: {$search: text} }); - promisedDocs.populate('report'); - promisedDocs.populate('template'); - const docs = await promisedDocs; - const result = docs.map(doc => { - if (doc.report) - doc.report.projectID = doc._id; - return doc; - }); - return result; - } catch(err) { - return err; + Query: { + /** + * Return list of projects + * @returns Promise with Project[] + */ + projects: (parent) => { + // console.debug("projects") + return new Promise((resolve, reject) => { + ProjectModel.find({ __t: "ProjectModel" }) + .populate("report") + .populate("template") + .then((docs) => { + // console.debug("docs", docs); + const results = docs.map((doc) => { + if (doc.report) doc.report.projectID = doc._id; + return doc; + }); + resolve(results); + }) + .catch((err) => { + reject(err); + }); + }); + }, // projects + projectsByTextSearch: async (parent, { text }) => { + try { + const promisedDocs = ProjectModel.find({ $text: { $search: text } }); + promisedDocs.populate("report"); + promisedDocs.populate("template"); + const docs = await promisedDocs; + const result = docs.map((doc) => { + if (doc.report) doc.report.projectID = doc._id; + return doc; + }); + return result; + } catch (err) { + return err; + } + }, + /** + * Return one project + * @param id - Project ID + * @returns Promise with Project found, or reject if not found + */ + project: (parent, { id }) => { + return new Promise((resolve, reject) => { + ProjectModel.findById(id) + .populate("report") + .populate("template") + .then((doc) => { + if (!doc) return reject("Invalid ID"); + if (doc.report) { + doc.report.projectID = id; } - }, - /** - * Return one project - * @param id - Project ID - * @returns Promise with Project found, or reject if not found - */ - project: (parent, {id}) => { - return new Promise((resolve, reject) => { - ProjectModel.findById(id).populate("report").populate("template").then(doc => { - if (!doc) - return reject("Invalid ID"); - if (doc.report) { - doc.report.projectID = id; - } - resolve(doc); - }).catch(err => { - reject(err); - }) - }) - }, // project - /** - * Return report of a project - * @param projectID - Project ID - * @returns Promise with Report found, or reject if not found - */ - report: (parent, {projectID}) => { - return new Promise(async (resolve, reject) => { - const report = await ReportModel.findOne({ project: projectID }).populate("projectSnapshot") - if (report) { - report.projectID = projectID; - await report.populate("projectSnapshot.modelAndDatasets.model") - await report.populate("projectSnapshot.modelAndDatasets.testDataset") - await report.populate("projectSnapshot.modelAndDatasets.groundTruthDataset") - if (report.tests) { - for (let test of report.tests) { - const algo = await getAlgorithm(test.algorithmGID); - // console.log("algo", algo) - if (!algo || !algo.gid) { - return reject("Invalid algo") - } - // const algo = getByGID(test.algorithmGID); - test.algorithm = algo; - } - } - resolve(report) - } - else - reject("Invalid project ID"); - }) - }, // report - }, // Query - Mutation: { - /** - * Create new project from input. - * @todo validate - * @param project - New project data - * @returns Promise with new Project data, including project ID - */ - createProject: (parent, {project}) => { - console.debug("createProject", project); - if (!project.projectInfo.name) { - return Promise.reject("Missing variable") + resolve(doc); + }) + .catch((err) => { + reject(err); + }); + }); + }, // project + /** + * Return report of a project + * @param projectID - Project ID + * @returns Promise with Report found, or reject if not found + */ + report: (parent, { projectID }) => { + return new Promise(async (resolve, reject) => { + const report = await ReportModel.findOne({ + project: projectID, + }).populate("projectSnapshot"); + if (report) { + report.projectID = projectID; + await report.populate("projectSnapshot.modelAndDatasets.model"); + await report.populate("projectSnapshot.modelAndDatasets.testDataset"); + await report.populate( + "projectSnapshot.modelAndDatasets.groundTruthDataset" + ); + if (report.tests) { + for (let test of report.tests) { + const algo = await getAlgorithm(test.algorithmGID); + // console.log("algo", algo) + if (!algo || !algo.gid) { + return reject("Invalid algo"); + } + // const algo = getByGID(test.algorithmGID); + test.algorithm = algo; } - // project.status = "NoReport"; - return new Promise((resolve, reject) => { - ProjectModel.create(project).then((doc) => { - // console.debug("doc", doc); - // doc.save().then(()) - resolve(doc); - }).catch(err => { - reject(err) - }) - }) - }, // createProject - /** - * Create new project from template. - * @todo validate - * @param project - New project data - * @returns Promise with new Project data, including project ID - */ - createProjectFromTemplate: (parent, {project, templateId}) => { - // console.debug("createProjectFromTemplate", templateId, project); - return new Promise(async (resolve, reject) => { - ProjectTemplateModel.findById(templateId).then(template => { - // console.log("template", template) - const newProject = { - ...template.toObject(), - _id: mongoose.Types.ObjectId(), - template: template._id, - projectInfo: project.projectInfo, - } - // console.log("newProject", newProject) - ProjectModel.create(newProject).then((doc) => { - // console.debug("doc", doc); - // doc.save().then(()) - resolve(doc); - }).catch(err => { - reject(err) - }) - }).catch(e => { - reject("Invalid project template id"); - }); - }) - }, // createProject - /** - * Delete project. - * @param id - Project ID - * @returns Promise of ID of project to delete. - */ - deleteProject: (parent, {id}) => { - console.debug("deleteProject", id); - return new Promise((resolve, reject) => { - ProjectModel.findByIdAndDelete(id).then(result => { - if (!result) - return reject("Invalid ID") - resolve(id); - }).catch(err => { - reject(err); - }) - }) - }, // deleteProject - /** - * Update project - * @param id - Project ID - * @param project - project data to update - * @returns Promise of updated Project - */ - updateProject: (parent, {id, project}) => { - // console.log("updateProject", id, project) - return new Promise(async (resolve, reject) => { - try { - const modelAndDatasets = project.modelAndDatasets; - if (modelAndDatasets) { - if (modelAndDatasets.modelId) { - modelAndDatasets.model = mongoose.Types.ObjectId(modelAndDatasets.modelId); - delete modelAndDatasets.modelId; - } - if (modelAndDatasets.testDatasetId) { - modelAndDatasets.testDataset = mongoose.Types.ObjectId(modelAndDatasets.testDatasetId); - delete modelAndDatasets.testDatasetId; - } - if (modelAndDatasets.groundTruthDatasetId) { - modelAndDatasets.groundTruthDataset = mongoose.Types.ObjectId(modelAndDatasets.groundTruthDatasetId); - delete modelAndDatasets.groundTruthDatasetId; - } - } - const doc = await ProjectModel.findByIdAndUpdate(id, project, { new:true }) - await doc.populate("report") - await doc.populate("template") - await doc.populate("modelAndDatasets.model") - await doc.populate("modelAndDatasets.testDataset") - await doc.populate("modelAndDatasets.groundTruthDataset") - resolve(doc); - } catch (err) { - reject(err); - } - }) - }, // updateProject - /** - * Make a copy of the project - * @param id - Project ID - * @returns Promise of new cloned Project - */ - cloneProject: (parent, {id}) => { - return new Promise((resolve, reject) => { - ProjectModel.findById(id).then(doc => { - if (!doc) - return reject("Invalid ID") - let newdoc = new ProjectModel(doc); - newdoc._id = mongoose.Types.ObjectId(); - newdoc.fromPlugin = false; - newdoc.projectInfo.name = `Copy of ${doc.projectInfo.name}`; - // newdoc.inputBlockData = {}; - newdoc.report = null; - newdoc.isNew = true; - newdoc.save().then(doc => { - resolve(doc); - }); - }).catch(err => { - reject(err); - }) - }) - }, // cloneProject - /** - * Make a copy of the project - * @param id - Project ID - * @returns Promise of new cloned Project - */ - saveProjectAsTemplate: (parent, {projectId, templateInfo}) => { - return new Promise((resolve, reject) => { - ProjectModel.findById(projectId).then(doc => { - if (!doc) - return reject("Invalid ID") - // let newdoc = new ProjectTemplateModel(doc); - let newdoc = { - ...doc.toObject(), - _id: mongoose.Types.ObjectId(), - projectInfo: templateInfo, - fromPlugin: false, - } - // newdoc.inputBlockData = {}; - delete newdoc.template; - delete newdoc.report; - delete newdoc.inputBlockData; - delete newdoc.testInformationData; - delete newdoc.__t; - newdoc.isNew = true; - ProjectTemplateModel.create(newdoc).then((doc) => { - // console.debug("doc", doc); - // doc.save().then(()) - resolve(doc); - }).catch(err => { - reject(err) - }) - }).catch(err => { - reject(err); - }) - }) - }, // cloneProject - /** - * Generate report - * @param projectID - Project ID - * @param algorithms - List of algorithms to run - * @returns Promise of updated Project - */ - generateReport: (parent, {projectID, algorithms}) => { - console.debug("generateReport", projectID, algorithms); - return new Promise(async (resolve, reject) => { - let proj = await ProjectModel.findById(projectID).populate("report"); - if (!proj) - return reject("Invalid project ID"); - // console.log("proj", proj) - if (proj.report && (proj.report.status === "GeneratingReport" || proj.report.status === "RunningTests")) - return reject("Previous report generation still running"); - // const needToRunTests = algorithms && algorithms.length > 0 && proj.testInformationData; - let reportObj = { - project: proj._id, - projectSnapshot: proj, - // status: needToRunTests?"RunningTests":"GeneratingReport", - timeStart: moment().toDate(), - timeTaken: 0, - totalTestTimeTaken: 0, - inputBlockData: proj.inputBlockData, - } - - if (algorithms && algorithms.length > 0 && proj.testInformationData) { - // TODO: run tasks - let tests = []; - for (const gid of algorithms) { - const inputSchema = await getAlgorithmInputSchema(gid); - if (Object.keys(inputSchema) === 0) { // make sure gid exists - console.log("Invalid GID"); - continue; - } - let test = proj.testInformationData.find(e => e.algorithmGID === gid); - if (!test) { - // continue; - // assume that no arguments input and check validity - // const res = validator.validate({}, inputSchema); - test = { - algorithmGID: gid, - testArguments: {}, - // isTestArgumentsValid: true, - } - if (!ProjectModel.isTestArgumentsValid(test)) { - console.log("Invalid arguments for algo", gid) - continue; + } + resolve(report); + } else reject("Invalid project ID"); + }); + }, // report + }, // Query + Mutation: { + /** + * Create new project from input. + * @todo validate + * @param project - New project data + * @returns Promise with new Project data, including project ID + */ + createProject: (parent, { project }) => { + console.debug("createProject", project); + if (!project.projectInfo.name) { + return Promise.reject("Missing variable"); + } + // project.status = "NoReport"; + return new Promise((resolve, reject) => { + ProjectModel.create(project) + .then((doc) => { + // console.debug("doc", doc); + // doc.save().then(()) + resolve(doc); + }) + .catch((err) => { + reject(err); + }); + }); + }, // createProject + /** + * Create new project from template. + * @todo validate + * @param project - New project data + * @returns Promise with new Project data, including project ID + */ + createProjectFromTemplate: (parent, { project, templateId }) => { + // console.debug("createProjectFromTemplate", templateId, project); + return new Promise(async (resolve, reject) => { + ProjectTemplateModel.findById(templateId) + .then((template) => { + // console.log("template", template) + const newProject = { + ...template.toObject(), + _id: mongoose.Types.ObjectId(), + template: template._id, + projectInfo: project.projectInfo, + }; + // console.log("newProject", newProject) + ProjectModel.create(newProject) + .then((doc) => { + // console.debug("doc", doc); + // doc.save().then(()) + resolve(doc); + }) + .catch((err) => { + reject(err); + }); + }) + .catch((e) => { + reject("Invalid project template id"); + }); + }); + }, // createProject + /** + * Delete project. + * @param id - Project ID + * @returns Promise of ID of project to delete. + */ + deleteProject: (parent, { id }) => { + console.debug("deleteProject", id); + return new Promise((resolve, reject) => { + ProjectModel.findByIdAndDelete(id) + .then((result) => { + if (!result) return reject("Invalid ID"); + resolve(id); + }) + .catch((err) => { + reject(err); + }); + }); + }, // deleteProject + /** + * Update project + * @param id - Project ID + * @param project - project data to update + * @returns Promise of updated Project + */ + updateProject: (parent, { id, project }) => { + // console.log("updateProject", id, project) + return new Promise(async (resolve, reject) => { + try { + const modelAndDatasets = project.modelAndDatasets; + if (modelAndDatasets) { + if (modelAndDatasets.modelId) { + modelAndDatasets.model = mongoose.Types.ObjectId( + modelAndDatasets.modelId + ); + delete modelAndDatasets.modelId; + } + if (modelAndDatasets.testDatasetId) { + modelAndDatasets.testDataset = mongoose.Types.ObjectId( + modelAndDatasets.testDatasetId + ); + delete modelAndDatasets.testDatasetId; + } + if (modelAndDatasets.groundTruthDatasetId) { + modelAndDatasets.groundTruthDataset = mongoose.Types.ObjectId( + modelAndDatasets.groundTruthDatasetId + ); + delete modelAndDatasets.groundTruthDatasetId; + } + } + const doc = await ProjectModel.findByIdAndUpdate(id, project, { + new: true, + }); + await doc.populate("report"); + await doc.populate("template"); + await doc.populate("modelAndDatasets.model"); + await doc.populate("modelAndDatasets.testDataset"); + await doc.populate("modelAndDatasets.groundTruthDataset"); + resolve(doc); + } catch (err) { + reject(err); + } + }); + }, // updateProject + /** + * Make a copy of the project + * @param id - Project ID + * @returns Promise of new cloned Project + */ + cloneProject: (parent, { id }) => { + return new Promise((resolve, reject) => { + ProjectModel.findById(id) + .then((doc) => { + if (!doc) return reject("Invalid ID"); + let newdoc = new ProjectModel(doc); + newdoc._id = mongoose.Types.ObjectId(); + newdoc.fromPlugin = false; + newdoc.projectInfo.name = `Copy of ${doc.projectInfo.name}`; + // newdoc.inputBlockData = {}; + newdoc.report = null; + newdoc.isNew = true; + newdoc.save().then((doc) => { + resolve(doc); + }); + }) + .catch((err) => { + reject(err); + }); + }); + }, // cloneProject + /** + * Make a copy of the project + * @param id - Project ID + * @returns Promise of new cloned Project + */ + saveProjectAsTemplate: (parent, { projectId, templateInfo }) => { + return new Promise((resolve, reject) => { + ProjectModel.findById(projectId) + .then((doc) => { + if (!doc) return reject("Invalid ID"); + // let newdoc = new ProjectTemplateModel(doc); + let newdoc = { + ...doc.toObject(), + _id: mongoose.Types.ObjectId(), + projectInfo: templateInfo, + fromPlugin: false, + }; + // newdoc.inputBlockData = {}; + delete newdoc.template; + delete newdoc.report; + delete newdoc.inputBlockData; + delete newdoc.testInformationData; + delete newdoc.__t; + newdoc.isNew = true; + ProjectTemplateModel.create(newdoc) + .then((doc) => { + // console.debug("doc", doc); + // doc.save().then(()) + resolve(doc); + }) + .catch((err) => { + reject(err); + }); + }) + .catch((err) => { + reject(err); + }); + }); + }, // cloneProject + /** + * Generate report + * @param projectID - Project ID + * @param algorithms - List of algorithms to run + * @returns Promise of updated Project + */ + generateReport: (parent, { projectID, algorithms }) => { + console.debug("generateReport", projectID, algorithms); + return new Promise(async (resolve, reject) => { + let proj = await ProjectModel.findById(projectID).populate("report"); + if (!proj) return reject("Invalid project ID"); + // console.log("proj", proj) + if ( + proj.report && + (proj.report.status === "GeneratingReport" || + proj.report.status === "RunningTests") + ) + return reject("Previous report generation still running"); + // const needToRunTests = algorithms && algorithms.length > 0 && proj.testInformationData; + let reportObj = { + project: proj._id, + projectSnapshot: proj, + // status: needToRunTests?"RunningTests":"GeneratingReport", + timeStart: moment().toDate(), + timeTaken: 0, + totalTestTimeTaken: 0, + inputBlockData: proj.inputBlockData, + }; - } - proj.testInformationData.push(test); - proj = await proj.save(); - } else { - if (!ProjectModel.isTestArgumentsValid(test)) { - console.log("Invalid arguments for algo", gid) - continue; - } - } - // if (!test.isTestArgumentsValid) - // continue; - // validate input - // const res = validator.validate(test.testArguments, inputSchema); - // if (!res.valid) { - // console.warn("Invalid arguments", test.testArguments); - // continue; - // } - let obj = { - algorithmGID: gid, - testArguments: test.testArguments, - status: "Pending", - progress: 0, - } - tests.push(obj); - } - reportObj.tests = tests; - } else { - // no tests to run, just generate - } - const needToRunTests = reportObj.tests && reportObj.tests.length > 0; - const modelAndDatasets = proj.modelAndDatasets.toObject(); - if (needToRunTests && (!modelAndDatasets || !modelAndDatasets.model || !modelAndDatasets.testDataset)) { - return reject("No model and test dataset defined"); - } - reportObj.status = needToRunTests ? "RunningTests" : "GeneratingReport"; - if (proj.report) { // has existing report object - const reportId = proj.report._id; - const doc = await ReportModel.findByIdAndUpdate(reportId, reportObj, { new:true }) - queueTests(doc, modelAndDatasets); - doc.projectID = proj._id; - if (!needToRunTests) - await generateReport(reportId); - resolve(doc); - } else { - // create new report object - const report = new ReportModel(reportObj); - const newDoc = await report.save(); - newDoc.projectID = proj._id; - proj.report = newDoc._id; - await proj.save(); - queueTests(newDoc, modelAndDatasets); - if (!needToRunTests) - await generateReport(newDoc._id); - resolve(newDoc); - } - }) - }, // generateReport - /** - * Cancel tests that are running during report generation - * @param projectID - Project ID - * @param algorithms - List of algorithms to cancel - * @returns Promise of updated Project - */ - cancelTestRuns: (parent, {projectID, algorithms}) => { - // console.debug("updateProject", id, JSON.stringify(project,null,2)); - return new Promise(async (resolve, reject) => { - let report = await ReportModel.findOne({ project: projectID }) - if (!report) - return reject("Report not found"); - if (report.status != "GeneratingReport" && report.status != "RunningTests") - return reject("Report is not generating"); - const tasks = report.tests; - let numUpdates = 0; - for (let algorithmGID of algorithms) { - const test = tasks.find(e => e.algorithmGID === algorithmGID); - if (test) { - if (test.status === "Pending") { - await cancelTestRun(report, test) - numUpdates++; - test.status = "Cancelled"; - } else if (test.status === "Running") { - await cancelTestRun(report, test) - numUpdates++; - // TOD: send job cancellation - test.status = "Cancelled"; - } - } - } - let isRunning = false; - for (let task of tasks) { - if (task.status === "Pending" || task.status === "Running") { - isRunning = true; - break; - } - } - // check if no more jobs running - if (!isRunning) { - numUpdates++; - // report.status = "ReportGenerated"; - } - if (numUpdates > 0) - report = await report.save(); - // TODO: Generate report anyway, with input blocks - report.projectID = projectID; - report = await report.save(); - if (!isRunning) { - generateReport(report._id); - } - resolve(report); - }) - }, // generateReport - }, // Mutation - Subscription: { - testTaskUpdatedNoFilter: { - resolve: payload => payload.testTaskUpdated, - subscribe: () => pubsub.asyncIterator(['TEST_TASK_UPDATED']) - }, - testTaskUpdated: { - subscribe: withFilter( - () => pubsub.asyncIterator(['TEST_TASK_UPDATED']), - (payload, variables) => { - return payload.testTaskUpdated.projectID === variables.projectID; - }, - ), - }, - reportStatusUpdatedNoFilter: { - resolve: payload => { - const updated = payload.reportStatusUpdated; - updated.projectID = updated.project; - return updated; - }, - subscribe: () => pubsub.asyncIterator(['REPORT_STATUS_UPDATED']), - }, - reportStatusUpdated: { - subscribe: withFilter( - () => pubsub.asyncIterator(['REPORT_STATUS_UPDATED']), - (payload, variables) => { - return payload.reportStatusUpdated.project === variables.projectID; - }, - ), - }, - }, // Subscription -} + if (algorithms && algorithms.length > 0 && proj.testInformationData) { + // TODO: run tasks + let tests = []; + for (const gid of algorithms) { + const inputSchema = await getAlgorithmInputSchema(gid); + if (Object.keys(inputSchema) === 0) { + // make sure gid exists + console.log("Invalid GID"); + continue; + } + let test = proj.testInformationData.find( + (e) => e.algorithmGID === gid + ); + if (!test) { + // continue; + // assume that no arguments input and check validity + // const res = validator.validate({}, inputSchema); + test = { + algorithmGID: gid, + testArguments: {}, + // isTestArgumentsValid: true, + }; + if (!ProjectModel.isTestArgumentsValid(test)) { + console.log("Invalid arguments for algo", gid); + continue; + } + proj.testInformationData.push(test); + proj = await proj.save(); + } else { + if (!ProjectModel.isTestArgumentsValid(test)) { + console.log("Invalid arguments for algo", gid); + continue; + } + } + // if (!test.isTestArgumentsValid) + // continue; + // validate input + // const res = validator.validate(test.testArguments, inputSchema); + // if (!res.valid) { + // console.warn("Invalid arguments", test.testArguments); + // continue; + // } + let obj = { + algorithmGID: gid, + testArguments: test.testArguments, + status: "Pending", + progress: 0, + }; + tests.push(obj); + } + reportObj.tests = tests; + } else { + // no tests to run, just generate + } + const needToRunTests = reportObj.tests && reportObj.tests.length > 0; + const modelAndDatasets = proj.modelAndDatasets.toObject(); + if ( + needToRunTests && + (!modelAndDatasets || + !modelAndDatasets.model || + !modelAndDatasets.testDataset) + ) { + return reject("No model and test dataset defined"); + } + reportObj.status = needToRunTests ? "RunningTests" : "GeneratingReport"; + if (proj.report) { + // has existing report object + const reportId = proj.report._id; + const doc = await ReportModel.findByIdAndUpdate(reportId, reportObj, { + new: true, + }); + queueTests(doc, modelAndDatasets); + doc.projectID = proj._id; + if (!needToRunTests) await generateReport(reportId); + resolve(doc); + } else { + // create new report object + const report = new ReportModel(reportObj); + const newDoc = await report.save(); + newDoc.projectID = proj._id; + proj.report = newDoc._id; + await proj.save(); + queueTests(newDoc, modelAndDatasets); + if (!needToRunTests) await generateReport(newDoc._id); + resolve(newDoc); + } + }); + }, // generateReport + /** + * Cancel tests that are running during report generation + * @param projectID - Project ID + * @param algorithms - List of algorithms to cancel + * @returns Promise of updated Project + */ + cancelTestRuns: (parent, { projectID, algorithms }) => { + // console.debug("updateProject", id, JSON.stringify(project,null,2)); + return new Promise(async (resolve, reject) => { + let report = await ReportModel.findOne({ project: projectID }); + if (!report) return reject("Report not found"); + if ( + report.status != "GeneratingReport" && + report.status != "RunningTests" + ) + return reject("Report is not generating"); + const tasks = report.tests; + let numUpdates = 0; + for (let algorithmGID of algorithms) { + const test = tasks.find((e) => e.algorithmGID === algorithmGID); + if (test) { + if (test.status === "Pending") { + await cancelTestRun(report, test); + numUpdates++; + test.status = "Cancelled"; + } else if (test.status === "Running") { + await cancelTestRun(report, test); + numUpdates++; + // TOD: send job cancellation + test.status = "Cancelled"; + } + } + } + let isRunning = false; + for (let task of tasks) { + if (task.status === "Pending" || task.status === "Running") { + isRunning = true; + break; + } + } + // check if no more jobs running + if (!isRunning) { + numUpdates++; + // report.status = "ReportGenerated"; + } + if (numUpdates > 0) report = await report.save(); + // TODO: Generate report anyway, with input blocks + report.projectID = projectID; + report = await report.save(); + if (!isRunning) { + generateReport(report._id); + } + resolve(report); + }); + }, // generateReport + }, // Mutation + Subscription: { + testTaskUpdatedNoFilter: { + resolve: (payload) => payload.testTaskUpdated, + subscribe: () => pubsub.asyncIterator(["TEST_TASK_UPDATED"]), + }, + testTaskUpdated: { + subscribe: withFilter( + () => pubsub.asyncIterator(["TEST_TASK_UPDATED"]), + (payload, variables) => { + return payload.testTaskUpdated.projectID === variables.projectID; + } + ), + }, + reportStatusUpdatedNoFilter: { + resolve: (payload) => { + const updated = payload.reportStatusUpdated; + updated.projectID = updated.project; + return updated; + }, + subscribe: () => pubsub.asyncIterator(["REPORT_STATUS_UPDATED"]), + }, + reportStatusUpdated: { + subscribe: withFilter( + () => pubsub.asyncIterator(["REPORT_STATUS_UPDATED"]), + (payload, variables) => { + return payload.reportStatusUpdated.project === variables.projectID; + } + ), + }, + }, // Subscription +}; -export default resolvers; \ No newline at end of file +export default resolvers; From e06a11004dbc55f8955b47945bfd2241b7e01de6 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 3 Jul 2023 13:14:56 +0800 Subject: [PATCH 019/176] add apiConfig to project schema --- .../graphql/modules/project/project.graphql | 270 ++++++++++-------- ai-verify-apigw/models/project.model.mjs | 9 + 2 files changed, 152 insertions(+), 127 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/project/project.graphql b/ai-verify-apigw/graphql/modules/project/project.graphql index 5fdee84f1..ea8dc5634 100644 --- a/ai-verify-apigw/graphql/modules/project/project.graphql +++ b/ai-verify-apigw/graphql/modules/project/project.graphql @@ -1,194 +1,210 @@ type LayoutItemProperties { - justifyContent: String - alignItems: String - textAlign: String - color: String - bgcolor: String + justifyContent: String + alignItems: String + textAlign: String + color: String + bgcolor: String } type ReportWidgetItem { - widgetGID: String! - key: String! - layoutItemProperties: LayoutItemProperties - properties: JSON + widgetGID: String! + key: String! + layoutItemProperties: LayoutItemProperties + properties: JSON } enum WidgetLayoutResizeHandleType { - s, - w, - e, - n, - sw, - nw, - se, - ne + s + w + e + n + sw + nw + se + ne } type WidgetLayout { - i: String! - x: Int! - y: Int! - w: Int! - h: Int! - maxW: Int - maxH: Int - minW: Int - minH: Int - static: Boolean - isDraggable: Boolean - isResizable: Boolean - resizeHandles: [WidgetLayoutResizeHandleType] - isBounded: Boolean + i: String! + x: Int! + y: Int! + w: Int! + h: Int! + maxW: Int + maxH: Int + minW: Int + minH: Int + static: Boolean + isDraggable: Boolean + isResizable: Boolean + resizeHandles: [WidgetLayoutResizeHandleType] + isBounded: Boolean } type Page { - layouts: [WidgetLayout]! - reportWidgets: [ReportWidgetItem]! + layouts: [WidgetLayout]! + reportWidgets: [ReportWidgetItem]! } - type ProjectInformation { - name: String! - description: String - reportTitle: String - company: String + name: String! + description: String + reportTitle: String + company: String } type GlobalVariable { - key: String! - value: String! + key: String! + value: String! } enum ProjectReportStatus { - NoReport - RunningTests - GeneratingReport - ReportGenerated - ReportError + NoReport + RunningTests + GeneratingReport + ReportGenerated + ReportError } type Report { - projectID: ObjectID! # id of project - projectSnapshot: Project # snapshot of project at time creation - status: ProjectReportStatus! # report status - timeStart: DateTime - timeTaken: Int - totalTestTimeTaken: Int - inputBlockData: JSON # snapshot of input block data - tests: [TestEngineTask] # tests that are run for this report + projectID: ObjectID! # id of project + projectSnapshot: Project # snapshot of project at time creation + status: ProjectReportStatus! # report status + timeStart: DateTime + timeTaken: Int + totalTestTimeTaken: Int + inputBlockData: JSON # snapshot of input block data + tests: [TestEngineTask] # tests that are run for this report } type ModelAndDatasets { - model: ModelFile, - testDataset: Dataset, - groundTruthDataset: Dataset, - groundTruthColumn: String, + model: ModelFile + testDataset: Dataset + groundTruthDataset: Dataset + groundTruthColumn: String +} + +type APIConfigType { + requestBody: JSON + parameters: JSON } type Project { - id: ObjectID! - template: ProjectTemplate - projectInfo: ProjectInformation! - globalVars: [GlobalVariable] - pages: [Page] - inputBlockData: JSON - testInformationData: [TestInformation] - report: Report - createdAt: DateTime - updatedAt: DateTime - modelAndDatasets: ModelAndDatasets + id: ObjectID! + template: ProjectTemplate + projectInfo: ProjectInformation! + globalVars: [GlobalVariable] + pages: [Page] + inputBlockData: JSON + testInformationData: [TestInformation] + apiConfig: APIConfigType + report: Report + createdAt: DateTime + updatedAt: DateTime + modelAndDatasets: ModelAndDatasets } input LayoutItemPropertiesInput { - justifyContent: String @constraint(maxLength: 128) - alignItems: String @constraint(maxLength: 128) - textAlign: String @constraint(maxLength: 128) - color: String @constraint(maxLength: 128) - bgcolor: String @constraint(maxLength: 128) + justifyContent: String @constraint(maxLength: 128) + alignItems: String @constraint(maxLength: 128) + textAlign: String @constraint(maxLength: 128) + color: String @constraint(maxLength: 128) + bgcolor: String @constraint(maxLength: 128) } input ReportWidgetItemInput { - widgetGID: String! @constraint(minLength: 1, maxLength: 128) # ref id of widget - key: String @constraint(minLength: 1, maxLength: 128) - layoutItemProperties: LayoutItemPropertiesInput - properties: JSON + widgetGID: String! @constraint(minLength: 1, maxLength: 128) # ref id of widget + key: String @constraint(minLength: 1, maxLength: 128) + layoutItemProperties: LayoutItemPropertiesInput + properties: JSON } input WidgetLayoutInput { - i: String! @constraint(minLength: 1, maxLength: 128) # ref id of widget - x: Int! @constraint(min: 0, max:12) - y: Int! @constraint(min: 0, max:36) - w: Int! @constraint(min: 0, max:12) - h: Int! @constraint(min: 0, max:36) - maxW: Int @constraint(min: 0, max:12) - maxH: Int @constraint(min: 0, max:36) - minW: Int @constraint(min: 0, max:12) - minH: Int @constraint(min: 0, max:36) - static: Boolean - isDraggable: Boolean - isResizable: Boolean - resizeHandles: [WidgetLayoutResizeHandleType] - isBounded: Boolean + i: String! @constraint(minLength: 1, maxLength: 128) # ref id of widget + x: Int! @constraint(min: 0, max: 12) + y: Int! @constraint(min: 0, max: 36) + w: Int! @constraint(min: 0, max: 12) + h: Int! @constraint(min: 0, max: 36) + maxW: Int @constraint(min: 0, max: 12) + maxH: Int @constraint(min: 0, max: 36) + minW: Int @constraint(min: 0, max: 12) + minH: Int @constraint(min: 0, max: 36) + static: Boolean + isDraggable: Boolean + isResizable: Boolean + resizeHandles: [WidgetLayoutResizeHandleType] + isBounded: Boolean } input PageInput { - layouts: [WidgetLayoutInput] - reportWidgets: [ReportWidgetItemInput] + layouts: [WidgetLayoutInput] + reportWidgets: [ReportWidgetItemInput] } - input ProjectInformationInput { - name: String @constraint(minLength: 1, maxLength: 128) - description: String @constraint(maxLength: 256) - reportTitle: String @constraint(maxLength: 128) - company: String @constraint(maxLength: 128) + name: String @constraint(minLength: 1, maxLength: 128) + description: String @constraint(maxLength: 256) + reportTitle: String @constraint(maxLength: 128) + company: String @constraint(maxLength: 128) } input GlobalVariableInput { - key: String! @constraint(minLength: 1, maxLength: 128) - value: String! @constraint(minLength: 1, maxLength: 128) + key: String! @constraint(minLength: 1, maxLength: 128) + value: String! @constraint(minLength: 1, maxLength: 128) } input ModelAndDatasetsInput { - modelId: ObjectID - testDatasetId: ObjectID - groundTruthDatasetId: ObjectID - groundTruthColumn: String @constraint(minLength: 1, maxLength: 128) + modelId: ObjectID + testDatasetId: ObjectID + groundTruthDatasetId: ObjectID + groundTruthColumn: String @constraint(minLength: 1, maxLength: 128) +} + +input APIConfigInput { + requestBody: JSON + parameters: JSON } input ProjectInput { projectInfo: ProjectInformationInput - globalVars: [GlobalVariableInput] - pages: [PageInput] - inputBlockData: JSON - modelAndDatasets: ModelAndDatasetsInput - testInformationData: [TestInformationInput] - # inputBlockGIDs: [String] + globalVars: [GlobalVariableInput] + pages: [PageInput] + inputBlockData: JSON + modelAndDatasets: ModelAndDatasetsInput + testInformationData: [TestInformationInput] + apiConfig: APIConfigInput + # inputBlockGIDs: [String] } type Query { projects: [Project] - projectsByTextSearch(text: String): [Project!]! - project(id: ObjectID!): Project - # get report of project - report(projectID: ObjectID!): Report + projectsByTextSearch(text: String): [Project!]! + project(id: ObjectID!): Project + # get report of project + report(projectID: ObjectID!): Report } type Mutation { - createProject(project: ProjectInput!): Project - createProjectFromTemplate(project: ProjectInput!, templateId: String!): Project - deleteProject(id: ObjectID!): ObjectID - updateProject(id: ObjectID!, project: ProjectInput!): Project - cloneProject(id: ObjectID!): Project - saveProjectAsTemplate(projectId: ObjectID!, templateInfo: ProjectInformationInput!): ProjectTemplate - # Generate report and run the algorithms specified. Return ID of report - generateReport(projectID: ObjectID!, algorithms: [String]!): Report - cancelTestRuns(projectID: ObjectID!, algorithms: [String]!): Report + createProject(project: ProjectInput!): Project + createProjectFromTemplate( + project: ProjectInput! + templateId: String! + ): Project + deleteProject(id: ObjectID!): ObjectID + updateProject(id: ObjectID!, project: ProjectInput!): Project + cloneProject(id: ObjectID!): Project + saveProjectAsTemplate( + projectId: ObjectID! + templateInfo: ProjectInformationInput! + ): ProjectTemplate + # Generate report and run the algorithms specified. Return ID of report + generateReport(projectID: ObjectID!, algorithms: [String]!): Report + cancelTestRuns(projectID: ObjectID!, algorithms: [String]!): Report } type Subscription { - testTaskUpdatedNoFilter: TestEngineTask - testTaskUpdated(projectID: ObjectID!): TestEngineTask - reportStatusUpdatedNoFilter: Report - reportStatusUpdated(projectID: ObjectID!): Report + testTaskUpdatedNoFilter: TestEngineTask + testTaskUpdated(projectID: ObjectID!): TestEngineTask + reportStatusUpdatedNoFilter: Report + reportStatusUpdated(projectID: ObjectID!): Report } diff --git a/ai-verify-apigw/models/project.model.mjs b/ai-verify-apigw/models/project.model.mjs index 9e0e75d1b..f8a324237 100644 --- a/ai-verify-apigw/models/project.model.mjs +++ b/ai-verify-apigw/models/project.model.mjs @@ -75,10 +75,19 @@ export const ProjectTemplateModel = model( projectTemplateSchema ); +/** + * Configuration specific to configuration data for API connector + */ +const apiConfigSchema = new Schema({ + requestBody: { type: Object }, + parameters: { type: Object }, +}); + export const projectSchema = new Schema( { template: { type: Schema.Types.ObjectId, ref: "ProjectTemplateModel" }, // reference to template used inputBlockData: { type: Object, default: {} }, + apiConfig: apiConfigSchema, testInformationData: [ { algorithmGID: { type: String }, From db71dd0efb118ae72f497f1ad8c0e427465a5006 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 3 Jul 2023 14:44:09 +0800 Subject: [PATCH 020/176] move apiConfig to under modelAndDatasets --- .../graphql/modules/project/project.graphql | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/project/project.graphql b/ai-verify-apigw/graphql/modules/project/project.graphql index ea8dc5634..2dbc3dec6 100644 --- a/ai-verify-apigw/graphql/modules/project/project.graphql +++ b/ai-verify-apigw/graphql/modules/project/project.graphql @@ -79,6 +79,7 @@ type Report { type ModelAndDatasets { model: ModelFile + apiConfig: APIConfigType testDataset: Dataset groundTruthDataset: Dataset groundTruthColumn: String @@ -97,7 +98,6 @@ type Project { pages: [Page] inputBlockData: JSON testInformationData: [TestInformation] - apiConfig: APIConfigType report: Report createdAt: DateTime updatedAt: DateTime @@ -153,18 +153,19 @@ input GlobalVariableInput { value: String! @constraint(minLength: 1, maxLength: 128) } +input APIConfigInput { + requestBody: JSON + parameters: JSON +} + input ModelAndDatasetsInput { modelId: ObjectID + apiConfig: APIConfigInput testDatasetId: ObjectID groundTruthDatasetId: ObjectID groundTruthColumn: String @constraint(minLength: 1, maxLength: 128) } -input APIConfigInput { - requestBody: JSON - parameters: JSON -} - input ProjectInput { projectInfo: ProjectInformationInput globalVars: [GlobalVariableInput] @@ -172,7 +173,6 @@ input ProjectInput { inputBlockData: JSON modelAndDatasets: ModelAndDatasetsInput testInformationData: [TestInformationInput] - apiConfig: APIConfigInput # inputBlockGIDs: [String] } From b5230795c60ad6d52b84cd690a5f2cd54ac9503b Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 3 Jul 2023 14:45:15 +0800 Subject: [PATCH 021/176] add exception handling for queueTests and pass the mongoose document of modelAndDatasets to queueTest --- .../graphql/modules/project/project.mjs | 34 ++- ai-verify-apigw/lib/testEngineQueue.mjs | 215 ++++++++++-------- ai-verify-apigw/models/project.model.mjs | 2 +- ai-verify-apigw/models/report.model.mjs | 57 +++-- 4 files changed, 185 insertions(+), 123 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/project/project.mjs b/ai-verify-apigw/graphql/modules/project/project.mjs index 619488b91..437c66306 100644 --- a/ai-verify-apigw/graphql/modules/project/project.mjs +++ b/ai-verify-apigw/graphql/modules/project/project.mjs @@ -378,7 +378,7 @@ const resolvers = { // no tests to run, just generate } const needToRunTests = reportObj.tests && reportObj.tests.length > 0; - const modelAndDatasets = proj.modelAndDatasets.toObject(); + const modelAndDatasets = proj.modelAndDatasets; if ( needToRunTests && (!modelAndDatasets || @@ -394,10 +394,19 @@ const resolvers = { const doc = await ReportModel.findByIdAndUpdate(reportId, reportObj, { new: true, }); - queueTests(doc, modelAndDatasets); - doc.projectID = proj._id; - if (!needToRunTests) await generateReport(reportId); - resolve(doc); + try { + if (needToRunTests) { + await queueTests(doc, modelAndDatasets); + } else { + await generateReport(reportId); + } + doc.projectID = proj._id; + resolve(doc); + } catch (err) { + doc.status = "ReportError"; + await doc.save(); + reject(err); + } } else { // create new report object const report = new ReportModel(reportObj); @@ -405,9 +414,18 @@ const resolvers = { newDoc.projectID = proj._id; proj.report = newDoc._id; await proj.save(); - queueTests(newDoc, modelAndDatasets); - if (!needToRunTests) await generateReport(newDoc._id); - resolve(newDoc); + try { + if (needToRunTests) { + await queueTests(newDoc, modelAndDatasets); + } else { + await generateReport(newDoc._id); + } + resolve(newDoc); + } catch (err) { + newDoc.status = "ReportError"; + await newDoc.save(); + reject(err); + } } }); }, // generateReport diff --git a/ai-verify-apigw/lib/testEngineQueue.mjs b/ai-verify-apigw/lib/testEngineQueue.mjs index 57c6d093d..b54ba8a25 100644 --- a/ai-verify-apigw/lib/testEngineQueue.mjs +++ b/ai-verify-apigw/lib/testEngineQueue.mjs @@ -1,18 +1,18 @@ -'use strict' +"use strict"; /** * Test job queue module * @module lib/testJobQueue */ -import * as _ from 'lodash'; -import moment from 'moment'; -import {Worker} from "node:worker_threads"; -import { cwd } from 'node:process'; -import path from 'node:path'; +import * as _ from "lodash"; +import moment from "moment"; +import { Worker } from "node:worker_threads"; +import { cwd } from "node:process"; +import path from "node:path"; -import redisConnect from './redisClient.mjs'; +import redisConnect from "./redisClient.mjs"; const redis = redisConnect(); -import { generateReport } from './report.mjs'; +import { generateReport } from "./report.mjs"; var worker = new Worker("./lib/testEngineWorker.mjs"); @@ -25,32 +25,31 @@ const redisCancelTopic = "task.cancel"; // handle events from workers /** Handle errors from workers */ -worker.on("error", error => { +worker.on("error", (error) => { console.error("worker error: %s", error); }); /** Handle unexpected worker exit */ -worker.on("exit", exitCode => { +worker.on("exit", (exitCode) => { console.error(`Worker exited with code ${exitCode}`); -}) +}); /** receive msg when all report tests finish running */ -worker.on("message", async msg => { +worker.on("message", async (msg) => { try { const resp = JSON.parse(msg); // console.debug(`Report message`, resp); - if (!resp.type) - return; - if (resp.type === 'TaskResponse') { + if (!resp.type) return; + if (resp.type === "TaskResponse") { await generateReport(resp.msg.reportId); - }else if (resp.type === 'ServiceResponse') { - console.log('Message received is: ', resp.msg); + } else if (resp.type === "ServiceResponse") { + console.log("Message received is: ", resp.msg); return; } - } catch(err) { + } catch (err) { console.error("worker message error: ", err); } -}) +}); /** * Update TestRun collection. @@ -60,137 +59,166 @@ worker.on("message", async msg => { */ function updateReport(reportId, update) { // logger.debug("updateTestRun %s %o", id, update) - return TestRun.findByIdAndUpdate( - { _id:reportId }, - { "$set": update }, - ); + return TestRun.findByIdAndUpdate({ _id: reportId }, { $set: update }); } /** - * Add the tests defined in the test runs objects to the job queue. Tests that are marked + * Add the tests defined in the test runs objects to the job queue. Tests that are marked * to be skipped are skipped. * @param report - Report object including the tests to run */ export const queueTests = async (report, modelAndDatasets) => { - console.log("modelAndDatasets", modelAndDatasets) + // console.log("modelAndDatasets", modelAndDatasets); + + if ( + !modelAndDatasets || + !modelAndDatasets.testDataset || + !modelAndDatasets.model + ) + throw new Error("Missing model and dataset information"); + const reportId = report._id.toString(); - const timeStart = moment(); + + const modelType = modelAndDatasets.model.type; + const mode = modelType === "API" ? "api" : "upload"; + if (mode === "api") { + if (!modelAndDatasets.model.modelAPI) { + throw new Error("Missing modelAPI information"); + } + if (!modelAndDatasets.apiConfig) { + throw new Error("Missing apiConfig information"); + } + } else { + if (!modelAndDatasets.model.modelFile) { + throw new Error("Missing modelFile information"); + } + } // const mode = testRun.scenario.mode; let commonProperties = { - "mode": "upload", - } + mode, + }; for (let test of report.tests) { console.log("test", test); - const id = `${reportId}-${test._id}` + const id = `${reportId}-${test._id}`; const taskId = `task:${id}`; let task = { ...commonProperties, id: taskId, algorithmId: "algo:" + test.algorithmGID, algorithmArgs: test.testArguments, - } - - if (modelAndDatasets && modelAndDatasets.testDataset) { - task.testDataset = modelAndDatasets.testDataset.filePath; - } - if (modelAndDatasets && modelAndDatasets.model) { + }; + + task.testDataset = modelAndDatasets.testDataset.filePath; + task.modelType = modelAndDatasets.model.modelType.toLowerCase(); + if (mode === "api") { + const modelAPI = modelAndDatasets.model.modelAPI.toObject(); + const apiConfig = { + ...modelAndDatasets.apiConfig.toObject(), + responseBody: { + field: modelAPI.response.field, + type: modelAPI.response.type, + }, + }; + if (modelAPI.authType !== "No Auth") { + apiConfig["authentication"] = modelAPI.authTypeConfig; + } + task.apiConfig = apiConfig; + task.apiSchema = modelAndDatasets.model.exportModelAPI(); + } else { task.modelFile = modelAndDatasets.model.filePath; } - if (modelAndDatasets && modelAndDatasets.groundTruthDataset) { + + if (modelAndDatasets.groundTruthDataset) { task.groundTruthDataset = modelAndDatasets.groundTruthDataset.filePath; } - if (modelAndDatasets && modelAndDatasets.model) { - task.modelType = modelAndDatasets.model.modelType.toLowerCase(); - } else { - task.modelType = 'classification'; + if (modelAndDatasets.groundTruthColumn) { + task.groundTruth = modelAndDatasets.groundTruthColumn; } - if (modelAndDatasets && modelAndDatasets.groundTruthColumn) { - task.groundTruth = modelAndDatasets.groundTruthColumn - } // console.log("Add task", task) // logger.debug("queue add %s %o", id, task); // set some initial parameters for the task await Promise.all([ - redis.hSet(taskId, 'status', 'Pending'), - redis.hSet(taskId, 'type', 'TaskResponse'), - redis.hSet(taskId, 'testId', test._id.toString()), - redis.hSet(taskId, 'reportId', reportId), - redis.hSet(taskId, 'gid', test.algorithmGID), - ]) - console.debug(`XADD ${redisQueueName} * task '${JSON.stringify(task)}'`) - await redis.xAdd(redisQueueName, "*", { "task": JSON.stringify(task) }) + redis.hSet(taskId, "status", "Pending"), + redis.hSet(taskId, "type", "TaskResponse"), + redis.hSet(taskId, "testId", test._id.toString()), + redis.hSet(taskId, "reportId", reportId), + redis.hSet(taskId, "gid", test.algorithmGID), + ]); + console.debug(`XADD ${redisQueueName} * task '${JSON.stringify(task)}'`); + await redis.xAdd(redisQueueName, "*", { task: JSON.stringify(task) }); // worker.postMessage({task}); } - -} +}; /** * @todo Implement job cancellation */ export const cancelTestRun = async (report, test) => { - const taskId = `task:${report._id}-${test._id}`; - if (test.status === 'Pending') { - // just delete redis - await redis.publish(redisCancelTopic, taskId); - await redis.del(taskId); // delete key - } else { // job running - await redis.publish(redisCancelTopic, taskId); - await Promise.all([ - redis.hSet(taskId, 'status', 'Cancelled'), - ]) - - } -} - + const taskId = `task:${report._id}-${test._id}`; + if (test.status === "Pending") { + // just delete redis + await redis.publish(redisCancelTopic, taskId); + await redis.del(taskId); // delete key + } else { + // job running + await redis.publish(redisCancelTopic, taskId); + await Promise.all([redis.hSet(taskId, "status", "Cancelled")]); + } +}; /** - * Add the dataset object to the service queue. + * Add the dataset object to the service queue. * @param dataset - Dataset object */ export const queueDataset = async (dataset) => { - const serviceId = `service:${dataset.id}`; let service = { serviceId: serviceId, filePath: dataset.filePath, - } + }; await Promise.all([ - redis.hSet(serviceId, 'status', 'Pending'), - redis.hSet(serviceId, 'type', 'ServiceResponse'), - redis.hSet(serviceId, 'serviceType', 'validateDataset'), - redis.hSet(serviceId, 'datasetId', dataset.id), - ]) - console.debug(`XADD ${redisServiceQueue} * validateDataset '${JSON.stringify(service)}'`) - await redis.xAdd(redisServiceQueue, "*", { "validateDataset": JSON.stringify(service) }) -} - + redis.hSet(serviceId, "status", "Pending"), + redis.hSet(serviceId, "type", "ServiceResponse"), + redis.hSet(serviceId, "serviceType", "validateDataset"), + redis.hSet(serviceId, "datasetId", dataset.id), + ]); + console.debug( + `XADD ${redisServiceQueue} * validateDataset '${JSON.stringify(service)}'` + ); + await redis.xAdd(redisServiceQueue, "*", { + validateDataset: JSON.stringify(service), + }); +}; /** - * Add the dataset object to the service queue. + * Add the dataset object to the service queue. * @param modelFile - ModelFile object */ export const queueModel = async (modelFile) => { const serviceId = `service:${modelFile.id}`; let service = { serviceId: serviceId, - mode: 'upload', + mode: "upload", filePath: modelFile.filePath, - } - console.debug("Add model validation service: %o", service) + }; + console.debug("Add model validation service: %o", service); await Promise.all([ - redis.hSet(serviceId, 'status', 'Pending'), - redis.hSet(serviceId, 'type', 'ServiceResponse'), - redis.hSet(serviceId, 'serviceType', 'validateModel'), - redis.hSet(serviceId, 'modelFileId', modelFile.id), - ]) - console.debug(`XADD ${redisServiceQueue} * validateModel '${JSON.stringify(service)}'`) - await redis.xAdd(redisServiceQueue, "*", { "validateModel": JSON.stringify(service) }) -} - + redis.hSet(serviceId, "status", "Pending"), + redis.hSet(serviceId, "type", "ServiceResponse"), + redis.hSet(serviceId, "serviceType", "validateModel"), + redis.hSet(serviceId, "modelFileId", modelFile.id), + ]); + console.debug( + `XADD ${redisServiceQueue} * validateModel '${JSON.stringify(service)}'` + ); + await redis.xAdd(redisServiceQueue, "*", { + validateModel: JSON.stringify(service), + }); +}; export const shutdown = () => { return new Promise(async (resolve, reject) => { @@ -198,9 +226,8 @@ export const shutdown = () => { await worker.terminate(); await redis.quit(); } catch (e) { - } finally { resolve(); } }); -} \ No newline at end of file +}; diff --git a/ai-verify-apigw/models/project.model.mjs b/ai-verify-apigw/models/project.model.mjs index f8a324237..a3bfba791 100644 --- a/ai-verify-apigw/models/project.model.mjs +++ b/ai-verify-apigw/models/project.model.mjs @@ -87,7 +87,6 @@ export const projectSchema = new Schema( { template: { type: Schema.Types.ObjectId, ref: "ProjectTemplateModel" }, // reference to template used inputBlockData: { type: Object, default: {} }, - apiConfig: apiConfigSchema, testInformationData: [ { algorithmGID: { type: String }, @@ -97,6 +96,7 @@ export const projectSchema = new Schema( ], modelAndDatasets: { model: { type: Schema.Types.ObjectId, ref: "ModelFileModel" }, + apiConfig: apiConfigSchema, testDataset: { type: Schema.Types.ObjectId, ref: "DatasetModel" }, groundTruthDataset: { type: Schema.Types.ObjectId, ref: "DatasetModel" }, groundTruthColumn: { type: String }, diff --git a/ai-verify-apigw/models/report.model.mjs b/ai-verify-apigw/models/report.model.mjs index 5bedab216..381652a5a 100644 --- a/ai-verify-apigw/models/report.model.mjs +++ b/ai-verify-apigw/models/report.model.mjs @@ -2,43 +2,60 @@ * Plugin mongoose models. NOTE: NOT USED */ -import { Schema, model } from 'mongoose'; +import { Schema, model } from "mongoose"; const errorMessageSchema = new Schema({ code: String, - severity: { type: String, enum: ['information', 'warning', 'critical' ], required: true }, + severity: { + type: String, + enum: ["information", "warning", "critical"], + required: true, + }, description: { type: String, required: true }, category: String, origin: String, component: String, -}) +}); const testEngineTaskSchema = new Schema({ - algorithmGID: { type: String, required: true }, - testArguments: { type: Object, required: false, default: {} }, // snapshot of test arguments - status: { type: String, required: true, enum: ["Pending", "Running", "Cancelled", "Success", "Error" ] }, - progress: { type: Number, default: 0, min: 0, max:100 }, // progress in percentage - timeStart: { type: Date }, + algorithmGID: { type: String, required: true }, + testArguments: { type: Object, required: false, default: {} }, // snapshot of test arguments + status: { + type: String, + required: true, + enum: ["Pending", "Running", "Cancelled", "Success", "Error"], + }, + progress: { type: Number, default: 0, min: 0, max: 100 }, // progress in percentage + timeStart: { type: Date }, timeTaken: { type: Number }, // in seconds - output: { type: Object }, - logFile: { type: String }, - errorMessages: [errorMessageSchema], -}) + output: { type: Object }, + logFile: { type: String }, + errorMessages: [errorMessageSchema], +}); /** * Report schema */ const schema = new Schema({ - project: { type: Schema.Types.ObjectId, ref: 'ProjectModel' }, - projectSnapshot: model('ProjectModel').schema, // save snapshot of project, not reference - status: { type: String, required: true, enum: ["NoReport","RunningTests","GeneratingReport","ReportGenerated","ReportError"], default:"NoReport" }, + project: { type: Schema.Types.ObjectId, ref: "ProjectModel" }, + projectSnapshot: model("ProjectModel").schema, // save snapshot of project, not reference + status: { + type: String, + required: true, + enum: [ + "NoReport", + "RunningTests", + "GeneratingReport", + "ReportGenerated", + "ReportError", + ], + default: "NoReport", + }, timeStart: { type: Date }, timeTaken: { type: Number }, // in seconds totalTestTimeTaken: { type: Number }, // total time taken to run the tests inputBlockData: { type: Object }, // snapshot of input block data - tests: [testEngineTaskSchema] -}) - - -export const ReportModel = model('ReportModel', schema); + tests: [testEngineTaskSchema], +}); +export const ReportModel = model("ReportModel", schema); From f0c3308026dc0edd1758e6895fba83133ef9af18 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 4 Jul 2023 12:00:17 +0800 Subject: [PATCH 022/176] add error message and description when test output is invalid --- ai-verify-apigw/lib/testEngineWorker.mjs | 472 ++++++++++++----------- 1 file changed, 237 insertions(+), 235 deletions(-) diff --git a/ai-verify-apigw/lib/testEngineWorker.mjs b/ai-verify-apigw/lib/testEngineWorker.mjs index 971b63765..6266050cc 100644 --- a/ai-verify-apigw/lib/testEngineWorker.mjs +++ b/ai-verify-apigw/lib/testEngineWorker.mjs @@ -1,4 +1,4 @@ -'use strict' +"use strict"; /** * Worker thread module to subscripe Redis HSET and process status update. The Redis @@ -7,38 +7,36 @@ * @module lib/testEngineWorker */ - import "dotenv/config.js"; -import {parentPort} from "node:worker_threads"; -import NodeCache from "node-cache"; +import { parentPort } from "node:worker_threads"; +import NodeCache from "node-cache"; import pubsub from "./apolloPubSub.mjs"; -import fs from 'node:fs'; -import path from 'path'; -import fsPromises from 'node:fs/promises'; +import fs from "node:fs"; +import path from "path"; +import fsPromises from "node:fs/promises"; -import _ from 'lodash'; -import moment from 'moment'; -import { Validator } from 'jsonschema'; +import _ from "lodash"; +import moment from "moment"; +import { Validator } from "jsonschema"; const validator = new Validator(); // imp: need to import this to connect db -import db from './mongodb.mjs'; +import db from "./mongodb.mjs"; -import redisConnect from './redisClient.mjs'; -import { ReportModel, DatasetModel, ModelFileModel } from '#models'; -import { getAlgorithmOutputSchema } from '#lib/plugin.mjs'; +import redisConnect from "./redisClient.mjs"; +import { ReportModel, DatasetModel, ModelFileModel } from "#models"; +import { getAlgorithmOutputSchema } from "#lib/plugin.mjs"; // import { DatasetModel } from "../models/dataset.model.mjs"; // need 2 connections, one for pub/sub, one for commands const redis = redisConnect(); const redis2 = redisConnect(); -function safeJSONParse (jstr) { - if (!jstr) - return null; +function safeJSONParse(jstr) { + if (!jstr) return null; try { - return JSON.parse(jstr) - } catch(err) { + return JSON.parse(jstr); + } catch (err) { console.error("Invalid JSON string: %s", jstr); return null; } @@ -49,12 +47,11 @@ function safeJSONParse (jstr) { * @param {string} key - hset key */ const processEvent = (key) => { - console.log("processEvent", key) - redis2.hGetAll(key).then(async values => { + console.log("processEvent", key); + redis2.hGetAll(key).then(async (values) => { // console.log("values", values); - if (!values.type) - return; - if ((values.type === 'TaskResponse')) { + if (!values.type) return; + if (values.type === "TaskResponse") { // console.error("TASK RESPONSE received is: ", values); const { testId, reportId } = values; if (!testId || !reportId) { @@ -63,11 +60,6 @@ const processEvent = (key) => { return; } - // console.debug("testId", testId, "reportId", reportId) - // if (values.errorMessages) { - // // temp solution until toolbox fix invalid JSON - // values.errorMessages = values.errorMessages.replaceAll("'",'"'); - // } let doc; try { doc = await ReportModel.findById(reportId); @@ -76,27 +68,35 @@ const processEvent = (key) => { if (!test) { throw "Test not found"; } - if (test.status === 'Cancelled') { + if (test.status === "Cancelled") { // throw "Test was cancelled, just delete key"; } else if (values.status === "Success") { // console.debug("status success") // validate output - const outputSchema = await getAlgorithmOutputSchema(values.gid) + const outputSchema = await getAlgorithmOutputSchema(values.gid); // console.log("outputSchema", outputSchema) const output = safeJSONParse(values.output); - const res = validator.validate(output, outputSchema) + const res = validator.validate(output, outputSchema); if (!res.valid) { - test.status = "Error"; + console.log("Test result does not match output schema"); + test.status = "Error"; + if (!test.errorMessages) test.errorMessages = []; + test.errorMessages.push({ + severity: "warning", + description: "Invalid test output", + }); } else { Object.assign(test, { status: values.status, progress: 100, timeStart: moment(values.startTime).toDate(), - timeTaken: values.elapsedTime?parseInt(values.elapsedTime):null, + timeTaken: values.elapsedTime + ? parseInt(values.elapsedTime) + : null, output, logFile: values.logFile, errorMessages: safeJSONParse(values.errorMessages), - }) + }); } } else { // console.debug("status not success") @@ -107,19 +107,18 @@ const processEvent = (key) => { // timeTaken: values.elapsedTime?parseInt(values.elapsedTime):null, logFile: values.logFile, errorMessages: safeJSONParse(values.errorMessages), - }) - + }); } // console.debug("save") doc = await doc.save(); const payload = { ...test.toObject(), projectID: doc.project.toString(), - } + }; // console.log("payload", payload); - pubsub.publish('TEST_TASK_UPDATED', { - testTaskUpdated: payload - }) + pubsub.publish("TEST_TASK_UPDATED", { + testTaskUpdated: payload, + }); // doc = await ReportModel.updateTestResult(reportId, testId, { // status: values.status, // progress: values.taskProgress, @@ -142,7 +141,11 @@ const processEvent = (key) => { return; } // console.log("updated doc", doc); - if (values.status === 'Success' || values.status === 'Error' || values.status === 'Cancelled') { + if ( + values.status === "Success" || + values.status === "Error" || + values.status === "Cancelled" + ) { // delete key once task completed // console.log("delete key", key) redis2.del(key); @@ -151,10 +154,10 @@ const processEvent = (key) => { let hasSuccess = false; let allDone = true; for (let test of doc.tests) { - if (test.status === 'Running' || test.status === 'Pending') { + if (test.status === "Running" || test.status === "Pending") { allDone = false; break; - } else if (test.status === 'Success') { + } else if (test.status === "Success") { hasSuccess = true; } } @@ -163,281 +166,284 @@ const processEvent = (key) => { // compute time taken for test and update // console.debug("updaing report status", doc) let totalTimeTaken = doc.tests.reduce((acc, test) => { - if (test.status === "Success") - acc += test.timeTaken; + if (test.status === "Success") acc += test.timeTaken; return acc; - }, 0) + }, 0); // console.log("allDone", hasSuccess) await ReportModel.findByIdAndUpdate( { _id: reportId }, - { "$set": { - totalTestTimeTaken: totalTimeTaken, - } }, + { + $set: { + totalTestTimeTaken: totalTimeTaken, + }, + } ); // inform parent test completed let msg = { type: "TaskResponse", msg: { - status: hasSuccess?'Success':'Error', + status: hasSuccess ? "Success" : "Error", reportId, - } - } + }, + }; parentPort.postMessage(JSON.stringify(msg)); } } // ToolboxResponse }); -} - +}; /** * Process service HSET events. * @param {string} key - hset key */ const processService = (key) => { + redis2.hGetAll(key).then(async (values) => { + if (!values.type) return; + if (values.serviceType === "validateDataset") { + const datasetId = values.datasetId; - redis2.hGetAll(key).then(async values => { - if (!values.type) - return; - if ((values.serviceType === 'validateDataset')) { - - const datasetId = values.datasetId; - - try { - let doc = await DatasetModel.findById(datasetId); - if (doc) { - if (doc.status !== 'Cancelled') { - if (values.status === 'done') { - - //logic to filter valid or invalid - if (values.validationResult === 'valid') { - - if (values.columns && values.dataFormat && values.serializedBy && values.numRows && values.numCols && values.logFile) { - let schema = safeJSONParse(values.columns) - // console.log("schema of ", datasetId," is: ") - // console.log(JSON.stringify(schema)); - // if (!schema) { - // console.log("values of ", datasetId," is: ") - // console.log(JSON.stringify(values)); - // } - let dataColumns = schema.map(e => { - return { - name: e.name, - datatype: e.datatype, - label: e.name, - } - }) - - const updatedDoc = await DatasetModel.findByIdAndUpdate( - { _id: datasetId }, - { - status: 'Valid', - dataColumns: dataColumns, - numRows: values.numRows, - numCols: values.numCols, - serializer: values.serializedBy, - dataFormat: values.dataFormat, - }, - { new: true} - ); + try { + let doc = await DatasetModel.findById(datasetId); + if (doc) { + if (doc.status !== "Cancelled") { + if (values.status === "done") { + //logic to filter valid or invalid + if (values.validationResult === "valid") { + if ( + values.columns && + values.dataFormat && + values.serializedBy && + values.numRows && + values.numCols && + values.logFile + ) { + let schema = safeJSONParse(values.columns); + // console.log("schema of ", datasetId," is: ") + // console.log(JSON.stringify(schema)); + // if (!schema) { + // console.log("values of ", datasetId," is: ") + // console.log(JSON.stringify(values)); + // } + let dataColumns = schema.map((e) => { + return { + name: e.name, + datatype: e.datatype, + label: e.name, + }; + }); - redis2.exists(key).then(result => { - if (result != 0) { - pubsub.publish('VALIDATE_DATASET_STATUS_UPDATED', { - validateDatasetStatusUpdated: updatedDoc.toObject() - }) - redis2.del(key); - return; - } - }) - - } - - } else if (values.validationResult === 'invalid') { - // console.log("validationResult not valid", values); - let message; - if (values.logFile) { - const dataArray = safeJSONParse(values.errorMessages); - const errorCategory = "DATA_OR_MODEL_ERROR"; - const errorDescription = dataArray.find(item => item.category === errorCategory)?.description; - - if (errorDescription) - message = errorDescription; - } const updatedDoc = await DatasetModel.findByIdAndUpdate( { _id: datasetId }, { - status: 'Invalid', - errorMessages: message, + status: "Valid", + dataColumns: dataColumns, + numRows: values.numRows, + numCols: values.numCols, + serializer: values.serializedBy, + dataFormat: values.dataFormat, }, { new: true } ); - redis2.exists(key).then(result => { + redis2.exists(key).then((result) => { if (result != 0) { - pubsub.publish('VALIDATE_DATASET_STATUS_UPDATED', { - validateDatasetStatusUpdated: updatedDoc.toObject() - }) - deleteFile(updatedDoc.toObject().filePath); + pubsub.publish("VALIDATE_DATASET_STATUS_UPDATED", { + validateDatasetStatusUpdated: updatedDoc.toObject(), + }); redis2.del(key); return; } - }) + }); } - - }else if (values.status === 'error') { + } else if (values.validationResult === "invalid") { + // console.log("validationResult not valid", values); let message; if (values.logFile) { const dataArray = safeJSONParse(values.errorMessages); const errorCategory = "DATA_OR_MODEL_ERROR"; - const errorDescription = dataArray.find(item => item.category === errorCategory)?.description; + const errorDescription = dataArray.find( + (item) => item.category === errorCategory + )?.description; - if (errorDescription) - message = errorDescription; + if (errorDescription) message = errorDescription; } - let updatedDoc = await DatasetModel.findByIdAndUpdate( + const updatedDoc = await DatasetModel.findByIdAndUpdate( { _id: datasetId }, { - status: 'Error', + status: "Invalid", errorMessages: message, }, { new: true } ); - redis2.exists(key).then(result => { + redis2.exists(key).then((result) => { if (result != 0) { - pubsub.publish('VALIDATE_DATASET_STATUS_UPDATED', { - validateDatasetStatusUpdated: updatedDoc.toObject() - }) + pubsub.publish("VALIDATE_DATASET_STATUS_UPDATED", { + validateDatasetStatusUpdated: updatedDoc.toObject(), + }); deleteFile(updatedDoc.toObject().filePath); redis2.del(key); return; } - }) - + }); } + } else if (values.status === "error") { + let message; + if (values.logFile) { + const dataArray = safeJSONParse(values.errorMessages); + const errorCategory = "DATA_OR_MODEL_ERROR"; + const errorDescription = dataArray.find( + (item) => item.category === errorCategory + )?.description; + + if (errorDescription) message = errorDescription; + } + let updatedDoc = await DatasetModel.findByIdAndUpdate( + { _id: datasetId }, + { + status: "Error", + errorMessages: message, + }, + { new: true } + ); + + redis2.exists(key).then((result) => { + if (result != 0) { + pubsub.publish("VALIDATE_DATASET_STATUS_UPDATED", { + validateDatasetStatusUpdated: updatedDoc.toObject(), + }); + deleteFile(updatedDoc.toObject().filePath); + redis2.del(key); + return; + } + }); } } - } catch (err) { - console.error("Error updating dataset validation result for ", datasetId,", error: ", err); - redis2.del(key); // delete key - return; } - } - else if ((values.serviceType === 'validateModel')) { + } catch (err) { + console.error( + "Error updating dataset validation result for ", + datasetId, + ", error: ", + err + ); + redis2.del(key); // delete key + return; + } + } else if (values.serviceType === "validateModel") { // console.log("modelresponse received: ", values) const modelFileId = values.modelFileId; - + try { let doc = await ModelFileModel.findById(modelFileId); - if (doc) { - if (doc.status !== 'Cancelled') { - if (values.status === 'done') { - - if (values.validationResult === 'valid') { - - if (values.modelFormat && values.serializedBy) { - let updatedDoc = await ModelFileModel.findByIdAndUpdate( - { _id: modelFileId }, - { - status: 'Valid', - serializer: values.serializedBy, - modelFormat: values.modelFormat, - }, - { new: true } - ); - - redis2.exists(key).then(result => { - if (result != 0) { - pubsub.publish('VALIDATE_MODEL_STATUS_UPDATED', { - validateModelStatusUpdated: updatedDoc.toObject() - }); - redis2.del(key); - return; - } - }) - - } - - } else if (values.validationResult === 'invalid') { - let message; - if (values.logFile) { - const dataArray = safeJSONParse(values.errorMessages); - const errorCategory = "DATA_OR_MODEL_ERROR"; - const errorDescription = dataArray.find(item => item.category === errorCategory)?.description; - - if (errorDescription) - message = errorDescription; - } + if (doc) { + if (doc.status !== "Cancelled") { + if (values.status === "done") { + if (values.validationResult === "valid") { + if (values.modelFormat && values.serializedBy) { let updatedDoc = await ModelFileModel.findByIdAndUpdate( { _id: modelFileId }, { - status: 'Invalid', - errorMessages: message, + status: "Valid", + serializer: values.serializedBy, + modelFormat: values.modelFormat, }, { new: true } ); - redis2.exists(key).then(result => { + redis2.exists(key).then((result) => { if (result != 0) { - pubsub.publish('VALIDATE_MODEL_STATUS_UPDATED', { - validateModelStatusUpdated: updatedDoc.toObject() + pubsub.publish("VALIDATE_MODEL_STATUS_UPDATED", { + validateModelStatusUpdated: updatedDoc.toObject(), }); - deleteFile(updatedDoc.toObject().filePath); redis2.del(key); return; } - }) - + }); } - - } else if (values.status === 'error') { + } else if (values.validationResult === "invalid") { let message; if (values.logFile) { const dataArray = safeJSONParse(values.errorMessages); const errorCategory = "DATA_OR_MODEL_ERROR"; - const errorDescription = dataArray.find(item => item.category === errorCategory)?.description; + const errorDescription = dataArray.find( + (item) => item.category === errorCategory + )?.description; - if (errorDescription) - message = errorDescription; + if (errorDescription) message = errorDescription; } let updatedDoc = await ModelFileModel.findByIdAndUpdate( { _id: modelFileId }, { - status: 'Error', + status: "Invalid", errorMessages: message, }, { new: true } ); - redis2.exists(key).then(result => { + redis2.exists(key).then((result) => { if (result != 0) { - pubsub.publish('VALIDATE_MODEL_STATUS_UPDATED', { - validateModelStatusUpdated: updatedDoc.toObject() + pubsub.publish("VALIDATE_MODEL_STATUS_UPDATED", { + validateModelStatusUpdated: updatedDoc.toObject(), }); deleteFile(updatedDoc.toObject().filePath); redis2.del(key); return; } - }) - + }); + } + } else if (values.status === "error") { + let message; + if (values.logFile) { + const dataArray = safeJSONParse(values.errorMessages); + const errorCategory = "DATA_OR_MODEL_ERROR"; + const errorDescription = dataArray.find( + (item) => item.category === errorCategory + )?.description; + + if (errorDescription) message = errorDescription; } + let updatedDoc = await ModelFileModel.findByIdAndUpdate( + { _id: modelFileId }, + { + status: "Error", + errorMessages: message, + }, + { new: true } + ); + + redis2.exists(key).then((result) => { + if (result != 0) { + pubsub.publish("VALIDATE_MODEL_STATUS_UPDATED", { + validateModelStatusUpdated: updatedDoc.toObject(), + }); + deleteFile(updatedDoc.toObject().filePath); + redis2.del(key); + return; + } + }); } } + } } catch (err) { - console.error("Error updating model validation result for ", modelFileId,", error: ", err); + console.error( + "Error updating model validation result for ", + modelFileId, + ", error: ", + err + ); redis2.del(key); return; } } }); -} +}; // Delete invalid files from fs, record to remain in db const deleteFile = (file) => { - try { - // console.log("Deleting invalid file: ", file) + // console.log("Deleting invalid file: ", file) if (file) { var filePath = file; if (!fs.existsSync(filePath)) { @@ -445,72 +451,68 @@ const deleteFile = (file) => { } else { let stat = fs.statSync(filePath); if (stat.isDirectory()) { - console.log("Removing dir %s", filePath) + console.log("Removing dir %s", filePath); try { fs.rmSync(filePath, { recursive: true, - force: true - }) + force: true, + }); } catch (err) { console.log("rm dir error", err); } - } else { - console.log("Removing file %s", filePath) + console.log("Removing file %s", filePath); fsPromises.unlink(filePath); } } - } else { - console.log("filePath is empty") + console.log("filePath is empty"); } } catch (err) { - console.error("delete err: ", err) + console.error("delete err: ", err); } - -} +}; // create cache to auto expire keys -let myJobs = new NodeCache({ stdTTL: 900 }); +let myJobs = new NodeCache({ stdTTL: 900 }); /** * Scan redis HSET keys to check for unprocessed task updates. */ -redis2.keys('task:*').then(keys => { +redis2.keys("task:*").then((keys) => { // if (keys && keys.length > 0) // logger.debug("keys to update: %o", keys); for (let key of keys) { - processEvent(key) + processEvent(key); } }); -redis2.keys('service:*').then(keys => { +redis2.keys("service:*").then((keys) => { // if (keys && keys.length > 0) // logger.debug("keys to update: %o", keys); for (let key of keys) { - processService(key) + processService(key); } }); /** * Subscribe to keychange notifications and process key updates. */ -redis.pSubscribe('__key*__:*', (event, channel) => { +redis.pSubscribe("__key*__:*", (event, channel) => { // console.log("key received : %o %o", event, channel); - const key = channel.replace('__keyspace@0__:',''); - if (key.includes("service:")){ + const key = channel.replace("__keyspace@0__:", ""); + if (key.includes("service:")) { // debounce events so that don't have excessive updates if (!myJobs.has(key)) { - myJobs.set(key,_.debounce(processService, 1000)); + myJobs.set(key, _.debounce(processService, 1000)); } myJobs.get(key)(key); - } else if (key.includes("task:")){ + } else if (key.includes("task:")) { // debounce events so that don't have excessive updates if (!myJobs.has(key)) { - myJobs.set(key,_.debounce(processEvent, 1000)); + myJobs.set(key, _.debounce(processEvent, 1000)); } myJobs.get(key)(key); } - -}) +}); From 29d0dd43719f2076ac51bba9316e87eb24912d36 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 4 Jul 2023 13:08:47 +0800 Subject: [PATCH 023/176] add option to run validator upon update --- ai-verify-apigw/graphql/modules/assets/model.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.mjs b/ai-verify-apigw/graphql/modules/assets/model.mjs index ae4f55d59..d75dcfa72 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.mjs +++ b/ai-verify-apigw/graphql/modules/assets/model.mjs @@ -74,7 +74,7 @@ const resolvers = { const newdoc = await ModelFileModel.findOneAndUpdate( { _id: modelFileID, type: "API" }, model, - { new: true } + { new: true, runValidators: true } ); resolve(newdoc); } catch (err) { From b69d73410655a6e34c1841cbec7b2e689700196d Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 7 Jul 2023 17:50:01 +0800 Subject: [PATCH 024/176] fix error where urlParams undefined --- ai-verify-apigw/models/model.model.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 76e80cdd5..141a4cd82 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -193,7 +193,7 @@ function _exportModelAPI(modelAPI) { }, }; - const url = modelAPI.url + modelAPI.urlParams; + const url = modelAPI.url + (modelAPI.urlParams || ""); const url_match = url.match(_url_pattern); // add servers spec["servers"] = [ From ddd09c8e9182efc9b92c293130ac0323284b202c Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 7 Jul 2023 17:51:30 +0800 Subject: [PATCH 025/176] Rename ModelAPIInput in modelapi.graphql to OpenAPIInput to over name duplicate --- ai-verify-apigw/graphql/modules/assets/model.graphql | 2 +- ai-verify-apigw/graphql/modules/assets/modelapi.graphql | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/model.graphql b/ai-verify-apigw/graphql/modules/assets/model.graphql index 9f2b6f908..04773c7dd 100644 --- a/ai-verify-apigw/graphql/modules/assets/model.graphql +++ b/ai-verify-apigw/graphql/modules/assets/model.graphql @@ -47,7 +47,7 @@ input ModelAPIInput { name: String @constraint(minLength: 0, maxLength: 128) description: String @constraint(minLength: 0, maxLength: 256) modelType: ModelType - modelAPI: ModelAPIInput # for model API inputs + modelAPI: OpenAPIInput # for model API inputs } type Query { diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index 46d47b845..3e163c71c 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -158,7 +158,7 @@ input OpenAPIResponseInput { field: String @constraint(minLength: 1, maxLength: 128) # for object, define the prediction field use dot, e.g. xxx.yyy, to denote nested field } -input ModelAPIRequestConfigInput { +input OpenAPIRequestConfigInput { rateLimit: Int! @constraint(min: -1) # max Int of requests per minute batchStrategy: ModelAPIRequestConfigBatchStrategy! batchLimit: Int @constraint(min: -1) # max Int of requests in each batch @@ -166,7 +166,7 @@ input ModelAPIRequestConfigInput { requestTimeout: Int! @constraint(min: 1) # request connection timeout in ms } -input ModelAPIInput { +input OpenAPIInput { method: OpenAPIMethod url: URL urlParams: String @constraint(maxLength: 2048) @@ -176,5 +176,5 @@ input ModelAPIInput { parameters: OpenAPIParametersInput requestBody: OpenAPIRequestBodyInput response: OpenAPIResponseInput - requestConfig: ModelAPIRequestConfigInput + requestConfig: OpenAPIRequestConfigInput } From ab65d75677558bcc32344840cdde70a83bd57ae6 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 10 Jul 2023 19:43:47 +0800 Subject: [PATCH 026/176] update api connector api to rework the handing for array and support complex serialization for parameters and request body --- .../graphql/modules/assets/modelapi.graphql | 95 +++--- ai-verify-apigw/models/model.model.mjs | 312 +++++++++++------- 2 files changed, 246 insertions(+), 161 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index 3e163c71c..ab6ff1bba 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -1,13 +1,13 @@ enum OpenAPIPrimitiveTypes { string - Int + number integer boolean } enum OpenAPIAllTypes { string - Int + number integer boolean array @@ -25,53 +25,45 @@ type OpenAPIAdditionalHeadersType { value: JSON! } -enum OpenAPIPathParamsStyles { - simple - label - matrix +type OpenAPIPathParamsType { + name: String! @constraint(minLength: 1, maxLength: 128) + type: OpenAPIPrimitiveTypes! } -type OpenAPIPathParamsType { +type QueryAPIParamsType { name: String! @constraint(minLength: 1, maxLength: 128) - type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes - maxItems: Int # max array items if itemType == 'array' - style: OpenAPIPathParamsStyles - explode: Boolean + type: OpenAPIPrimitiveTypes! } -enum OpenAPIQueryParamsStyles { - form - spaceDelimited - pipeDelimited - deepObject +type OpenAPIParametersPathType { + mediaType: OpenAPIMediaType! # default none + isArray: Boolean! # indicate if is array, default false, note that cannot be array if mediaType == 'none' + maxItems: Int @constraint(min: 1) # max array items if array + pathParams: [OpenAPIPathParamsType] } -type QueryParamsType { - name: String! @constraint(minLength: 1, maxLength: 128) - type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes - maxItems: Int # max array items if itemType == 'array' - style: OpenAPIQueryParamsStyles - explode: Boolean +type OpenAPIParametersQueryType { + mediaType: OpenAPIMediaType! # default none + name: String # name of query if mediaType !== 'none' + isArray: Boolean! # indicate if is array, default false, note that cannot be array if mediaType == 'none' + maxItems: Int @constraint(min: 1) # max array items if array + queryParams: [QueryAPIParamsType] } type OpenAPIParametersType { - pathParams: [OpenAPIPathParamsType] - queryParams: [QueryParamsType] + paths: OpenAPIParametersPathType + queries: OpenAPIParametersQueryType } type OpenAPIRequestBodyPropertyType { field: String! @constraint(minLength: 1, maxLength: 128) - type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes - maxItems: Int # max array items if itemType == 'array' - style: OpenAPIQueryParamsStyles - explode: Boolean + type: OpenAPIPrimitiveTypes! } type OpenAPIRequestBodyType { mediaType: OpenAPIMediaType! + isArray: Boolean! # indicate if is array, default false + maxItems: Int @constraint(min: 1) # max array items if array properties: [OpenAPIRequestBodyPropertyType]! } @@ -116,38 +108,43 @@ input OpenAPIAdditionalHeadersInput { input OpenAPIPathParamsInput { name: String! @constraint(minLength: 1, maxLength: 128) - type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes - maxItems: Int # max array items if itemType == 'array' - style: OpenAPIPathParamsStyles - explode: Boolean + type: OpenAPIPrimitiveTypes! } -input QueryParamsInput { +input OpenAPIQueryParamsInput { name: String! @constraint(minLength: 1, maxLength: 128) - type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes - maxItems: Int # max array items if itemType == 'array' - style: OpenAPIQueryParamsStyles - explode: Boolean + type: OpenAPIPrimitiveTypes! } -input OpenAPIParametersInput { +input OpenAPIParametersPathInput { + mediaType: OpenAPIMediaType! # default none + isArray: Boolean! # indicate if is array, default false, note that cannot be array if mediaType == 'none' + maxItems: Int @constraint(min: 1) # max array items if array pathParams: [OpenAPIPathParamsInput] - queryParams: [QueryParamsInput] +} + +input OpenAPIParametersQueryInput { + mediaType: OpenAPIMediaType! # default none + name: String # name of query if mediaType !== 'none' + isArray: Boolean! # indicate if is array, default false, note that cannot be array if mediaType == 'none' + maxItems: Int @constraint(min: 1) # max array items if array + queryParams: [OpenAPIQueryParamsInput] +} + +input OpenAPIParametersInput { + paths: OpenAPIParametersPathInput + queries: OpenAPIParametersQueryInput } input OpenAPIRequestBodyPropertyInput { field: String! @constraint(minLength: 1, maxLength: 128) - type: OpenAPIAllTypes! - itemType: OpenAPIPrimitiveTypes - maxItems: Int # max array items if itemType == 'array' - style: OpenAPIQueryParamsStyles - explode: Boolean + type: OpenAPIPrimitiveTypes! } input OpenAPIRequestBodyInput { mediaType: OpenAPIMediaType! + isArray: Boolean! # indicate if is array, default false + maxItems: Int @constraint(min: 1) # max array items if array properties: [OpenAPIRequestBodyPropertyInput] } diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 141a4cd82..1955cea57 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -4,61 +4,67 @@ import _ from "lodash"; const PRIMITIVE_TYPES = ["string", "number", "integer", "boolean"]; const ALL_TYPES = [...PRIMITIVE_TYPES, "array", "object"]; +const MEDIA_TYPES = [ + "none", + "application/x-www-form-urlencoded", + "multipart/form-data", + "application/json", + "text/plain", +]; + const modelAPIAdditionalHeadersSchema = new Schema({ name: { type: String, required: true }, type: { type: String, required: true, enum: PRIMITIVE_TYPES }, value: { type: Object, required: true }, }); -const modelAPIParametersSchema = new Schema({ +const modelAPIParametersPathSchema = new Schema({ + mediaType: { + type: String, + required: true, + enum: MEDIA_TYPES, + default: "none", + }, + isArray: { type: Boolean, required: true, default: false }, + maxItems: { type: Number }, // max array items if itemType == 'array' pathParams: [ { name: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - maxItems: { type: Number }, // max array items if itemType == 'array' - style: { - type: String, - enum: ["simple", "label", "matrix"], - default: "simple", - }, - explode: { type: Boolean, default: false }, + type: { type: String, required: true, enum: PRIMITIVE_TYPES }, }, ], +}); + +const modelAPIParametersQuerySchema = new Schema({ + mediaType: { type: String, required: true, enum: MEDIA_TYPES }, + name: { type: String }, + isArray: { type: Boolean, required: true, default: false }, + maxItems: { type: Number }, // max array items if itemType == 'array' queryParams: [ { name: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - maxItems: { type: Number }, // max array items if itemType == 'array' - style: { - type: String, - enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], - default: "form", - }, - explode: { type: Boolean, default: true }, + type: { type: String, required: true, enum: PRIMITIVE_TYPES }, }, ], }); +const modelAPIParametersSchema = new Schema({ + paths: modelAPIParametersPathSchema, + queries: modelAPIParametersQuerySchema, +}); + const modelAPIRequestBodySchema = new Schema({ mediaType: { type: String, required: true, - enum: ["none", "multipart/form-data", "application/x-www-form-urlencoded"], + enum: MEDIA_TYPES, }, + isArray: { type: Boolean, required: true, default: false }, + maxItems: { type: Number }, // max array items if itemType == 'array' properties: [ { field: { type: String, required: true }, - type: { type: String, required: true, enum: ALL_TYPES }, - itemType: { type: String, enum: PRIMITIVE_TYPES }, // only applicate if "type" is "array" - maxItems: { type: Number }, // max array items if itemType == 'array' - style: { - type: String, - enum: ["form", "spaceDelimited", "pipeDelimited", "deepObject"], - default: "form", - }, - explode: { type: Boolean, default: true }, + type: { type: String, required: true, enum: PRIMITIVE_TYPES }, }, ], }); @@ -248,77 +254,161 @@ function _exportModelAPI(modelAPI) { if ( path_match && modelAPI.parameters && - modelAPI.parameters.pathParams && - modelAPI.parameters.pathParams.length > 0 + modelAPI.parameters.paths && + modelAPI.parameters.paths.pathParams && + modelAPI.parameters.paths.pathParams.length > 0 ) { // console.log("path_match", path_match); - for (let item of path_match) { - let attr = item.replaceAll(/[{}]/g, ""); - const p = modelAPI.parameters.pathParams.find((p) => p.name === attr); - if (!p) { - throw new Error(`Path parameter {${attr}} not defined`); - } - let pobj = { - in: "path", - name: p.name, - required: true, - schema: { - type: p.type, - }, - }; - if (p.type === "array") { - if (p.maxItems) { - pobj.schema.maxItems = p.maxItems; + // const parameters = []; + const isComplex = modelAPI.parameters.paths.mediaType !== "none"; + if (!isComplex) { + for (let item of path_match) { + let attr = item.replaceAll(/[{}]/g, ""); + const p = modelAPI.parameters.paths.pathParams.find( + (p) => p.name === attr + ); + if (!p) { + throw new Error(`Path parameter {${attr}} not defined`); } - pobj.schema["items"] = { - type: p.itemType || "string", + let pobj = { + in: "path", + name: p.name, + required: true, + schema: { + type: p.type, + }, }; - } else if (p.type === "object") { - pobj.schema["properties"] = { - type: p.itemType || "string", + pathObj.parameters.push(pobj); + } + } else { + if (path_match.length != 1) { + // impose condition of only one path param for objects + throw new Error("Require one path variable for complex serialization"); + } + let name = path_match[0].replaceAll(/[{}]/g, ""); + if (!name || name.length == 0) { + throw new Error( + "Name field required for parameters with complex serialization" + ); + } + const properties = {}; + const required = []; + for (let p of modelAPI.parameters.paths.pathParams) { + properties[p.name] = { + type: p.type, }; + required.push(p.name); } - if (p.type === "array" || p.type === "object") { - pobj["style"] = p.style || "simple"; - pobj["explode"] = _.isBoolean(p.explode) ? p.explode : false; + const objectDefinition = { + type: "object", + properties, + required, + }; + if (modelAPI.parameters.paths.isArray) { + const schema = { + type: "array", + items: objectDefinition, + }; + if (modelAPI.parameters.paths.maxItems) { + schema.maxItems = modelAPI.parameters.paths.maxItems; + } + pathObj.parameters.push({ + in: "path", + name, + required: true, + content: { + [modelAPI.parameters.paths.mediaType]: { + schema, + }, + }, + }); + } else { + pathObj.parameters.push({ + in: "path", + name, + required: true, + content: { + [modelAPI.parameters.paths.mediaType]: { + schema: objectDefinition, + }, + }, + }); } - pathObj.parameters.push(pobj); } } // add query params if any if ( modelAPI.parameters && - modelAPI.parameters.queryParams && - modelAPI.parameters.queryParams.length > 0 + modelAPI.parameters.queries && + modelAPI.parameters.queries.queryParams && + modelAPI.parameters.queries.queryParams.length > 0 ) { // has query params - for (let p of modelAPI.parameters.queryParams) { - let pobj = { - in: "query", - name: p.name, - required: true, - schema: { - type: p.type, - }, - }; - if (p.type === "array") { - if (p.maxItems) { - pobj.schema.maxItems = p.maxItems; - } - pobj.schema["items"] = { - type: p.itemType || "string", + const isComplex = modelAPI.parameters.queries.mediaType !== "none"; + if (!isComplex) { + for (let p of modelAPI.parameters.queries.queryParams) { + let pobj = { + in: "query", + name: p.name, + required: true, + schema: { + type: p.type, + }, }; - } else if (p.type === "object") { - pobj.schema["properties"] = { - type: p.itemType || "string", + pathObj.parameters.push(pobj); + } + } else { + if ( + !modelAPI.parameters.queries.name || + modelAPI.parameters.queries.name.length == 0 + ) { + throw new Error( + "Name field required for parameters with complex serialization" + ); + } + const name = modelAPI.parameters.queries.name; + const properties = {}; + const required = []; + for (let p of modelAPI.parameters.queries.queryParams) { + properties[p.name] = { + type: p.type, }; + required.push(p.name); } - if (p.type === "array" || p.type === "object") { - pobj["style"] = p.style || "form"; - pobj["explode"] = _.isBoolean(p.explode) ? p.explode : true; + const objectDefinition = { + type: "object", + properties, + required, + }; + if (modelAPI.parameters.queries.isArray) { + const schema = { + type: "array", + items: objectDefinition, + }; + if (modelAPI.parameters.queries.maxItems) { + schema.maxItems = modelAPI.parameters.queries.maxItems; + } + pathObj.parameters.push({ + in: "query", + name, + content: { + [modelAPI.parameters.queries.mediaType]: { + schema, + }, + }, + }); + } else { + pathObj.parameters.push({ + in: "query", + name, + content: { + [modelAPI.parameters.queries.mediaType]: { + schema: objectDefinition, + }, + }, + }); } - pathObj.parameters.push(pobj); } } @@ -326,45 +416,43 @@ function _exportModelAPI(modelAPI) { if (modelAPI.requestBody && modelAPI.requestBody.mediaType !== "none") { let required = []; let properties = {}; - let encoding = {}; for (let prop of modelAPI.requestBody.properties) { required.push(prop.field); properties[prop.field] = { type: prop.type, }; - if (prop.type === "array") { - if (prop.maxItems) { - properties[prop.field]["maxItems"] = prop.maxItems; - } - properties[prop.field]["items"] = { - type: prop.itemType || "string", - }; - } else if (prop.type === "object") { - properties[prop.field]["properties"] = { - type: prop.itemType || "string", - }; - } - if (prop.type === "array" || prop.type === "object") { - encoding[prop.field] = { - style: prop.style || "form", - explode: _.isBoolean(prop.explode) ? prop.explode : true, - }; - } } - if (_.isEmpty(encoding)) encoding = undefined; - pathObj["requestBody"] = { - required: true, - content: { - [modelAPI.requestBody.mediaType]: { - schema: { - type: "object", - required, - properties, + const objectDefinition = { + type: "object", + required, + properties, + }; + if (modelAPI.requestBody.isArray) { + const schema = { + type: "array", + items: objectDefinition, + }; + if (modelAPI.requestBody.maxItems) { + schema.maxItems = modelAPI.requestBody.maxItems; + } + pathObj["requestBody"] = { + required: true, + content: { + [modelAPI.requestBody.mediaType]: { + schema, }, - ...(_.isEmpty(encoding) ? {} : { encoding }), }, - }, - }; + }; + } else { + pathObj["requestBody"] = { + required: true, + content: { + [modelAPI.requestBody.mediaType]: { + schema: objectDefinition, + }, + }, + }; + } } spec["paths"] = { From 1482687a66a5ca616bca68614a0e12e6e9303c1b Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Tue, 11 Jul 2023 17:47:57 +0800 Subject: [PATCH 027/176] add checks for path when url has path parameters --- ai-verify-apigw/graphql/modules/assets/modelapi.graphql | 1 + ai-verify-apigw/models/model.model.mjs | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index ab6ff1bba..9c7bf4f9f 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -143,6 +143,7 @@ input OpenAPIRequestBodyPropertyInput { input OpenAPIRequestBodyInput { mediaType: OpenAPIMediaType! + name: String # name of payload property when array isArray: Boolean! # indicate if is array, default false maxItems: Int @constraint(min: 1) # max array items if array properties: [OpenAPIRequestBodyPropertyInput] diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 1955cea57..56966dc63 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -335,6 +335,8 @@ function _exportModelAPI(modelAPI) { }); } } + } else if (path_match && path_match.length > 0) { + throw new Error("Path parameters not defined"); } // add query params if any @@ -345,7 +347,7 @@ function _exportModelAPI(modelAPI) { modelAPI.parameters.queries.queryParams.length > 0 ) { // has query params - const isComplex = modelAPI.parameters.queries.mediaType !== "none"; + const isComplex = modelAPI.parameters.queries.mediaType && modelAPI.parameters.queries.mediaType !== "none"; if (!isComplex) { for (let p of modelAPI.parameters.queries.queryParams) { let pobj = { @@ -414,6 +416,9 @@ function _exportModelAPI(modelAPI) { // add request body if any if (modelAPI.requestBody && modelAPI.requestBody.mediaType !== "none") { + if (modelAPI.method === 'GET') { + throw new Error("GET methods cannot have a request body") + } let required = []; let properties = {}; for (let prop of modelAPI.requestBody.properties) { From 048191c8c4489a83627349aeb47a0f7a7f78a522 Mon Sep 17 00:00:00 2001 From: Lionel Date: Tue, 11 Jul 2023 15:29:36 +0000 Subject: [PATCH 028/176] add init apiconnector --- test-engine-core-modules/poetry.lock | 590 ++++++++++++------ test-engine-core-modules/pyproject.toml | 1 + .../src/apiconnector/__init__.py | 0 .../src/apiconnector/__main__.py | 24 + .../src/apiconnector/apiconnector.py | 253 ++++++++ .../src/apiconnector/module_tests/__init__.py | 0 .../apiconnector/module_tests/plugin_test.py | 183 ++++++ .../user_defined_files/test_api_config.json | 26 + .../user_defined_files/test_api_schema.json | 86 +++ .../dist/test_engine_core-0.9.0.tar.gz | Bin 52553 -> 52592 bytes .../plugins/enums/model_plugin_type.py | 1 + .../plugins/plugins_manager.py | 1 + 12 files changed, 970 insertions(+), 195 deletions(-) create mode 100644 test-engine-core-modules/src/apiconnector/__init__.py create mode 100644 test-engine-core-modules/src/apiconnector/__main__.py create mode 100644 test-engine-core-modules/src/apiconnector/apiconnector.py create mode 100644 test-engine-core-modules/src/apiconnector/module_tests/__init__.py create mode 100644 test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py create mode 100644 test-engine-core-modules/src/apiconnector/user_defined_files/test_api_config.json create mode 100644 test-engine-core-modules/src/apiconnector/user_defined_files/test_api_schema.json diff --git a/test-engine-core-modules/poetry.lock b/test-engine-core-modules/poetry.lock index a849fd985..4a2f03480 100644 --- a/test-engine-core-modules/poetry.lock +++ b/test-engine-core-modules/poetry.lock @@ -26,38 +26,53 @@ files = [ six = ">=1.6.1,<2.0" wheel = ">=0.23.0,<1.0" +[[package]] +name = "attrs" +version = "23.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] + [[package]] name = "black" -version = "23.3.0" +version = "23.7.0" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, - {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, - {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, - {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, - {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, - {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, - {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, - {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, - {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, - {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, - {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, - {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, - {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, - {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, + {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, + {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, + {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, + {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, + {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, + {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, + {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, + {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, + {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, + {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, ] [package.dependencies] @@ -109,97 +124,97 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.1.0" +version = "3.2.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, - {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, - {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, - {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, - {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, - {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, - {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, + {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, + {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, ] [[package]] name = "click" -version = "8.1.3" +version = "8.1.4" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, + {file = "click-8.1.4-py3-none-any.whl", hash = "sha256:2739815aaa5d2c986a88f1e9230c55e17f0caad3d958a5e13ad0797c166db9e3"}, + {file = "click-8.1.4.tar.gz", hash = "sha256:b97d0c74955da062a7d4ef92fadb583806a585b2ea81958a81bd72726cbb8e37"}, ] [package.dependencies] @@ -229,13 +244,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.1" +version = "1.1.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, + {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, + {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, ] [package.extras] @@ -280,13 +295,13 @@ files = [ [[package]] name = "google-auth" -version = "2.20.0" +version = "2.21.0" description = "Google Authentication Library" optional = false python-versions = ">=3.6" files = [ - {file = "google-auth-2.20.0.tar.gz", hash = "sha256:030af34138909ccde0fbce611afc178f1d65d32fbff281f25738b1fe1c6f3eaa"}, - {file = "google_auth-2.20.0-py2.py3-none-any.whl", hash = "sha256:23b7b0950fcda519bfb6692bf0d5289d2ea49fc143717cc7188458ec620e63fa"}, + {file = "google-auth-2.21.0.tar.gz", hash = "sha256:b28e8048e57727e7cf0e5bd8e7276b212aef476654a09511354aa82753b45c66"}, + {file = "google_auth-2.21.0-py2.py3-none-any.whl", hash = "sha256:da3f18d074fa0f5a7061d99b9af8cee3aa6189c987af7c1b07d94566b6b11268"}, ] [package.dependencies] @@ -338,60 +353,60 @@ six = "*" [[package]] name = "grpcio" -version = "1.54.2" +version = "1.56.0" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.7" files = [ - {file = "grpcio-1.54.2-cp310-cp310-linux_armv7l.whl", hash = "sha256:40e1cbf69d6741b40f750f3cccc64326f927ac6145a9914d33879e586002350c"}, - {file = "grpcio-1.54.2-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:2288d76e4d4aa7ef3fe7a73c1c470b66ea68e7969930e746a8cd8eca6ef2a2ea"}, - {file = "grpcio-1.54.2-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:c0e3155fc5335ec7b3b70f15230234e529ca3607b20a562b6c75fb1b1218874c"}, - {file = "grpcio-1.54.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bf88004fe086c786dc56ef8dd6cb49c026833fdd6f42cb853008bce3f907148"}, - {file = "grpcio-1.54.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2be88c081e33f20630ac3343d8ad9f1125f32987968e9c8c75c051c9800896e8"}, - {file = "grpcio-1.54.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:33d40954199bddbb6a78f8f6f2b2082660f381cd2583ec860a6c2fa7c8400c08"}, - {file = "grpcio-1.54.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b52d00d1793d290c81ad6a27058f5224a7d5f527867e5b580742e1bd211afeee"}, - {file = "grpcio-1.54.2-cp310-cp310-win32.whl", hash = "sha256:881d058c5ccbea7cc2c92085a11947b572498a27ef37d3eef4887f499054dca8"}, - {file = "grpcio-1.54.2-cp310-cp310-win_amd64.whl", hash = "sha256:0212e2f7fdf7592e4b9d365087da30cb4d71e16a6f213120c89b4f8fb35a3ab3"}, - {file = "grpcio-1.54.2-cp311-cp311-linux_armv7l.whl", hash = "sha256:1e623e0cf99a0ac114f091b3083a1848dbc64b0b99e181473b5a4a68d4f6f821"}, - {file = "grpcio-1.54.2-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:66233ccd2a9371158d96e05d082043d47dadb18cbb294dc5accfdafc2e6b02a7"}, - {file = "grpcio-1.54.2-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:4cb283f630624ebb16c834e5ac3d7880831b07cbe76cb08ab7a271eeaeb8943e"}, - {file = "grpcio-1.54.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a1e601ee31ef30a9e2c601d0867e236ac54c922d32ed9f727b70dd5d82600d5"}, - {file = "grpcio-1.54.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8da84bbc61a4e92af54dc96344f328e5822d574f767e9b08e1602bb5ddc254a"}, - {file = "grpcio-1.54.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5008964885e8d23313c8e5ea0d44433be9bfd7e24482574e8cc43c02c02fc796"}, - {file = "grpcio-1.54.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a2f5a1f1080ccdc7cbaf1171b2cf384d852496fe81ddedeb882d42b85727f610"}, - {file = "grpcio-1.54.2-cp311-cp311-win32.whl", hash = "sha256:b74ae837368cfffeb3f6b498688a123e6b960951be4dec0e869de77e7fa0439e"}, - {file = "grpcio-1.54.2-cp311-cp311-win_amd64.whl", hash = "sha256:8cdbcbd687e576d48f7886157c95052825ca9948c0ed2afdc0134305067be88b"}, - {file = "grpcio-1.54.2-cp37-cp37m-linux_armv7l.whl", hash = "sha256:782f4f8662a2157c4190d0f99eaaebc602899e84fb1e562a944e5025929e351c"}, - {file = "grpcio-1.54.2-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:714242ad0afa63a2e6dabd522ae22e1d76e07060b5af2ddda5474ba4f14c2c94"}, - {file = "grpcio-1.54.2-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:f900ed4ad7a0f1f05d35f955e0943944d5a75f607a836958c6b8ab2a81730ef2"}, - {file = "grpcio-1.54.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96a41817d2c763b1d0b32675abeb9179aa2371c72aefdf74b2d2b99a1b92417b"}, - {file = "grpcio-1.54.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70fcac7b94f4c904152809a050164650ac81c08e62c27aa9f156ac518029ebbe"}, - {file = "grpcio-1.54.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fd6c6c29717724acf9fc1847c4515d57e4dc12762452457b9cb37461f30a81bb"}, - {file = "grpcio-1.54.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c2392f5b5d84b71d853918687d806c1aa4308109e5ca158a16e16a6be71041eb"}, - {file = "grpcio-1.54.2-cp37-cp37m-win_amd64.whl", hash = "sha256:51630c92591d6d3fe488a7c706bd30a61594d144bac7dee20c8e1ce78294f474"}, - {file = "grpcio-1.54.2-cp38-cp38-linux_armv7l.whl", hash = "sha256:b04202453941a63b36876a7172b45366dc0cde10d5fd7855c0f4a4e673c0357a"}, - {file = "grpcio-1.54.2-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:89dde0ac72a858a44a2feb8e43dc68c0c66f7857a23f806e81e1b7cc7044c9cf"}, - {file = "grpcio-1.54.2-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:09d4bfd84686cd36fd11fd45a0732c7628308d094b14d28ea74a81db0bce2ed3"}, - {file = "grpcio-1.54.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fc2b4edb938c8faa4b3c3ea90ca0dd89b7565a049e8e4e11b77e60e4ed2cc05"}, - {file = "grpcio-1.54.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61f7203e2767800edee7a1e1040aaaf124a35ce0c7fe0883965c6b762defe598"}, - {file = "grpcio-1.54.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e416c8baf925b5a1aff31f7f5aecc0060b25d50cce3a5a7255dc5cf2f1d4e5eb"}, - {file = "grpcio-1.54.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dc80c9c6b608bf98066a038e0172013a49cfa9a08d53335aefefda2c64fc68f4"}, - {file = "grpcio-1.54.2-cp38-cp38-win32.whl", hash = "sha256:8d6192c37a30a115f4663592861f50e130caed33efc4eec24d92ec881c92d771"}, - {file = "grpcio-1.54.2-cp38-cp38-win_amd64.whl", hash = "sha256:46a057329938b08e5f0e12ea3d7aed3ecb20a0c34c4a324ef34e00cecdb88a12"}, - {file = "grpcio-1.54.2-cp39-cp39-linux_armv7l.whl", hash = "sha256:2296356b5c9605b73ed6a52660b538787094dae13786ba53080595d52df13a98"}, - {file = "grpcio-1.54.2-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:c72956972e4b508dd39fdc7646637a791a9665b478e768ffa5f4fe42123d5de1"}, - {file = "grpcio-1.54.2-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:9bdbb7624d65dc0ed2ed8e954e79ab1724526f09b1efa88dcd9a1815bf28be5f"}, - {file = "grpcio-1.54.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c44e1a765b31e175c391f22e8fc73b2a2ece0e5e6ff042743d8109b5d2eff9f"}, - {file = "grpcio-1.54.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cc928cfe6c360c1df636cf7991ab96f059666ac7b40b75a769410cc6217df9c"}, - {file = "grpcio-1.54.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a08920fa1a97d4b8ee5db2f31195de4a9def1a91bc003544eb3c9e6b8977960a"}, - {file = "grpcio-1.54.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4864f99aac207e3e45c5e26c6cbb0ad82917869abc2f156283be86c05286485c"}, - {file = "grpcio-1.54.2-cp39-cp39-win32.whl", hash = "sha256:b38b3de8cff5bc70f8f9c615f51b48eff7313fc9aca354f09f81b73036e7ddfa"}, - {file = "grpcio-1.54.2-cp39-cp39-win_amd64.whl", hash = "sha256:be48496b0e00460717225e7680de57c38be1d8629dc09dadcd1b3389d70d942b"}, - {file = "grpcio-1.54.2.tar.gz", hash = "sha256:50a9f075eeda5097aa9a182bb3877fe1272875e45370368ac0ee16ab9e22d019"}, + {file = "grpcio-1.56.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fb34ace11419f1ae321c36ccaa18d81cd3f20728cd191250be42949d6845bb2d"}, + {file = "grpcio-1.56.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:008767c0aed4899e657b50f2e0beacbabccab51359eba547f860e7c55f2be6ba"}, + {file = "grpcio-1.56.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:17f47aeb9be0da5337f9ff33ebb8795899021e6c0741ee68bd69774a7804ca86"}, + {file = "grpcio-1.56.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43c50d810cc26349b093bf2cfe86756ab3e9aba3e7e681d360930c1268e1399a"}, + {file = "grpcio-1.56.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187b8f71bad7d41eea15e0c9812aaa2b87adfb343895fffb704fb040ca731863"}, + {file = "grpcio-1.56.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:881575f240eb5db72ddca4dc5602898c29bc082e0d94599bf20588fb7d1ee6a0"}, + {file = "grpcio-1.56.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c243b158dd7585021d16c50498c4b2ec0a64a6119967440c5ff2d8c89e72330e"}, + {file = "grpcio-1.56.0-cp310-cp310-win32.whl", hash = "sha256:8b3b2c7b5feef90bc9a5fa1c7f97637e55ec3e76460c6d16c3013952ee479cd9"}, + {file = "grpcio-1.56.0-cp310-cp310-win_amd64.whl", hash = "sha256:03a80451530fd3b8b155e0c4480434f6be669daf7ecba56f73ef98f94222ee01"}, + {file = "grpcio-1.56.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:64bd3abcf9fb4a9fa4ede8d0d34686314a7075f62a1502217b227991d9ca4245"}, + {file = "grpcio-1.56.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:fdc3a895791af4addbb826808d4c9c35917c59bb5c430d729f44224e51c92d61"}, + {file = "grpcio-1.56.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:4f84a6fd4482e5fe73b297d4874b62a535bc75dc6aec8e9fe0dc88106cd40397"}, + {file = "grpcio-1.56.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14e70b4dda3183abea94c72d41d5930c333b21f8561c1904a372d80370592ef3"}, + {file = "grpcio-1.56.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b5ce42a5ebe3e04796246ba50357f1813c44a6efe17a37f8dc7a5c470377312"}, + {file = "grpcio-1.56.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8219f17baf069fe8e42bd8ca0b312b875595e43a70cabf397be4fda488e2f27d"}, + {file = "grpcio-1.56.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:defdd14b518e6e468466f799aaa69db0355bca8d3a5ea75fb912d28ba6f8af31"}, + {file = "grpcio-1.56.0-cp311-cp311-win32.whl", hash = "sha256:50f4daa698835accbbcc60e61e0bc29636c0156ddcafb3891c987e533a0031ba"}, + {file = "grpcio-1.56.0-cp311-cp311-win_amd64.whl", hash = "sha256:59c4e606993a47146fbeaf304b9e78c447f5b9ee5641cae013028c4cca784617"}, + {file = "grpcio-1.56.0-cp37-cp37m-linux_armv7l.whl", hash = "sha256:b1f4b6f25a87d80b28dd6d02e87d63fe1577fe6d04a60a17454e3f8077a38279"}, + {file = "grpcio-1.56.0-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:c2148170e01d464d41011a878088444c13413264418b557f0bdcd1bf1b674a0e"}, + {file = "grpcio-1.56.0-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:0409de787ebbf08c9d2bca2bcc7762c1efe72eada164af78b50567a8dfc7253c"}, + {file = "grpcio-1.56.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66f0369d27f4c105cd21059d635860bb2ea81bd593061c45fb64875103f40e4a"}, + {file = "grpcio-1.56.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38fdf5bd0a1c754ce6bf9311a3c2c7ebe56e88b8763593316b69e0e9a56af1de"}, + {file = "grpcio-1.56.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:79d4c5911d12a7aa671e5eb40cbb50a830396525014d2d6f254ea2ba180ce637"}, + {file = "grpcio-1.56.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5d2fc471668a7222e213f86ef76933b18cdda6a51ea1322034478df8c6519959"}, + {file = "grpcio-1.56.0-cp37-cp37m-win_amd64.whl", hash = "sha256:991224fd485e088d3cb5e34366053691a4848a6b7112b8f5625a411305c26691"}, + {file = "grpcio-1.56.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:c6f36621aabecbaff3e70c4d1d924c76c8e6a7ffec60c331893640a4af0a8037"}, + {file = "grpcio-1.56.0-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:1eadd6de258901929223f422ffed7f8b310c0323324caf59227f9899ea1b1674"}, + {file = "grpcio-1.56.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:72836b5a1d4f508ffbcfe35033d027859cc737972f9dddbe33fb75d687421e2e"}, + {file = "grpcio-1.56.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f92a99ab0c7772fb6859bf2e4f44ad30088d18f7c67b83205297bfb229e0d2cf"}, + {file = "grpcio-1.56.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa08affbf672d051cd3da62303901aeb7042a2c188c03b2c2a2d346fc5e81c14"}, + {file = "grpcio-1.56.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2db108b4c8e29c145e95b0226973a66d73ae3e3e7fae00329294af4e27f1c42"}, + {file = "grpcio-1.56.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8674fdbd28266d8efbcddacf4ec3643f76fe6376f73283fd63a8374c14b0ef7c"}, + {file = "grpcio-1.56.0-cp38-cp38-win32.whl", hash = "sha256:bd55f743e654fb050c665968d7ec2c33f03578a4bbb163cfce38024775ff54cc"}, + {file = "grpcio-1.56.0-cp38-cp38-win_amd64.whl", hash = "sha256:c63bc5ac6c7e646c296fed9139097ae0f0e63f36f0864d7ce431cce61fe0118a"}, + {file = "grpcio-1.56.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:c0bc9dda550785d23f4f025be614b7faa8d0293e10811f0f8536cf50435b7a30"}, + {file = "grpcio-1.56.0-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:d596408bab632ec7b947761e83ce6b3e7632e26b76d64c239ba66b554b7ee286"}, + {file = "grpcio-1.56.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:76b6e6e1ee9bda32e6e933efd61c512e9a9f377d7c580977f090d1a9c78cca44"}, + {file = "grpcio-1.56.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7beb84ebd0a3f732625124b73969d12b7350c5d9d64ddf81ae739bbc63d5b1ed"}, + {file = "grpcio-1.56.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ec714bbbe9b9502177c842417fde39f7a267031e01fa3cd83f1ca49688f537"}, + {file = "grpcio-1.56.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4feee75565d1b5ab09cb3a5da672b84ca7f6dd80ee07a50f5537207a9af543a4"}, + {file = "grpcio-1.56.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b4638a796778329cc8e142e4f57c705adb286b3ba64e00b0fa91eeb919611be8"}, + {file = "grpcio-1.56.0-cp39-cp39-win32.whl", hash = "sha256:437af5a7673bca89c4bc0a993382200592d104dd7bf55eddcd141cef91f40bab"}, + {file = "grpcio-1.56.0-cp39-cp39-win_amd64.whl", hash = "sha256:4241a1c2c76e748023c834995cd916570e7180ee478969c2d79a60ce007bc837"}, + {file = "grpcio-1.56.0.tar.gz", hash = "sha256:4c08ee21b3d10315b8dc26f6c13917b20ed574cdbed2d2d80c53d5508fdcc0f2"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.54.2)"] +protobuf = ["grpcio-tools (>=1.56.0)"] [[package]] name = "h5py" @@ -464,12 +479,12 @@ files = [ [[package]] name = "jax" -version = "0.4.12" +version = "0.4.13" description = "Differentiate, compile, and transform Numpy code." optional = false python-versions = ">=3.8" files = [ - {file = "jax-0.4.12.tar.gz", hash = "sha256:d2de9a2388ffe002f16506d3ad1cc6e34d7536b98948e49c7e05bbcfe8e57998"}, + {file = "jax-0.4.13.tar.gz", hash = "sha256:03bfe6749dfe647f16f15f6616638adae6c4a7ca7167c75c21961ecfd3a3baaa"}, ] [package.dependencies] @@ -480,17 +495,16 @@ scipy = ">=1.7" [package.extras] australis = ["protobuf (>=3.13,<4)"] -ci = ["jaxlib (==0.4.11)"] -cpu = ["jaxlib (==0.4.12)"] -cuda = ["jaxlib (==0.4.12+cuda11.cudnn86)"] -cuda11-cudnn82 = ["jaxlib (==0.4.12+cuda11.cudnn82)"] -cuda11-cudnn86 = ["jaxlib (==0.4.12+cuda11.cudnn86)"] -cuda11-local = ["jaxlib (==0.4.12+cuda11.cudnn86)"] -cuda11-pip = ["jaxlib (==0.4.12+cuda11.cudnn86)", "nvidia-cublas-cu11 (>=11.11)", "nvidia-cuda-cupti-cu11 (>=11.8)", "nvidia-cuda-nvcc-cu11 (>=11.8)", "nvidia-cuda-runtime-cu11 (>=11.8)", "nvidia-cudnn-cu11 (>=8.8)", "nvidia-cufft-cu11 (>=10.9)", "nvidia-cusolver-cu11 (>=11.4)", "nvidia-cusparse-cu11 (>=11.7)"] -cuda12-local = ["jaxlib (==0.4.12+cuda12.cudnn88)"] -cuda12-pip = ["jaxlib (==0.4.12+cuda12.cudnn88)", "nvidia-cublas-cu12", "nvidia-cuda-cupti-cu12", "nvidia-cuda-nvcc-cu12", "nvidia-cuda-runtime-cu12", "nvidia-cudnn-cu12 (>=8.9)", "nvidia-cufft-cu12", "nvidia-cusolver-cu12", "nvidia-cusparse-cu12"] +ci = ["jaxlib (==0.4.12)"] +cpu = ["jaxlib (==0.4.13)"] +cuda = ["jaxlib (==0.4.13+cuda11.cudnn86)"] +cuda11-cudnn86 = ["jaxlib (==0.4.13+cuda11.cudnn86)"] +cuda11-local = ["jaxlib (==0.4.13+cuda11.cudnn86)"] +cuda11-pip = ["jaxlib (==0.4.13+cuda11.cudnn86)", "nvidia-cublas-cu11 (>=11.11)", "nvidia-cuda-cupti-cu11 (>=11.8)", "nvidia-cuda-nvcc-cu11 (>=11.8)", "nvidia-cuda-runtime-cu11 (>=11.8)", "nvidia-cudnn-cu11 (>=8.8)", "nvidia-cufft-cu11 (>=10.9)", "nvidia-cusolver-cu11 (>=11.4)", "nvidia-cusparse-cu11 (>=11.7)"] +cuda12-local = ["jaxlib (==0.4.13+cuda12.cudnn89)"] +cuda12-pip = ["jaxlib (==0.4.13+cuda12.cudnn89)", "nvidia-cublas-cu12", "nvidia-cuda-cupti-cu12", "nvidia-cuda-nvcc-cu12", "nvidia-cuda-runtime-cu12", "nvidia-cudnn-cu12 (>=8.9)", "nvidia-cufft-cu12", "nvidia-cusolver-cu12", "nvidia-cusparse-cu12"] minimum-jaxlib = ["jaxlib (==0.4.11)"] -tpu = ["jaxlib (==0.4.12)", "libtpu-nightly (==0.1.dev20230608)"] +tpu = ["jaxlib (==0.4.13)", "libtpu-nightly (==0.1.dev20230622)"] [[package]] name = "joblib" @@ -503,6 +517,41 @@ files = [ {file = "joblib-1.2.0.tar.gz", hash = "sha256:e1cee4a79e4af22881164f218d4311f60074197fb707e082e803b61f6d137018"}, ] +[[package]] +name = "jsonschema" +version = "4.18.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.18.0-py3-none-any.whl", hash = "sha256:b508dd6142bd03f4c3670534c80af68cd7bbff9ea830b9cf2625d4a3c49ddf60"}, + {file = "jsonschema-4.18.0.tar.gz", hash = "sha256:8caf5b57a990a98e9b39832ef3cb35c176fe331414252b6e1b26fd5866f891a4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] + +[[package]] +name = "jsonschema-specifications" +version = "2023.6.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema_specifications-2023.6.1-py3-none-any.whl", hash = "sha256:3d2b82663aff01815f744bb5c7887e2121a63399b49b104a3c96145474d091d7"}, + {file = "jsonschema_specifications-2023.6.1.tar.gz", hash = "sha256:ca1c4dd059a9e7b34101cf5b3ab7ff1d18b139f35950d598d629837ef66e8f28"}, +] + +[package.dependencies] +referencing = ">=0.28.0" + [[package]] name = "keras" version = "2.12.0" @@ -740,6 +789,22 @@ rsa = ["cryptography (>=3.0.0)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "openapi-schema-validator" +version = "0.6.0" +description = "OpenAPI schema validation for Python" +optional = false +python-versions = ">=3.8.0,<4.0.0" +files = [ + {file = "openapi_schema_validator-0.6.0-py3-none-any.whl", hash = "sha256:9e95b95b621efec5936245025df0d6a7ffacd1551e91d09196b3053040c931d7"}, + {file = "openapi_schema_validator-0.6.0.tar.gz", hash = "sha256:921b7c1144b856ca3813e41ecff98a4050f7611824dfc5c6ead7072636af0520"}, +] + +[package.dependencies] +jsonschema = ">=4.18.0,<5.0.0" +jsonschema-specifications = ">=2023.5.2,<2024.0.0" +rfc3339-validator = "*" + [[package]] name = "opt-einsum" version = "3.3.0" @@ -829,13 +894,13 @@ files = [ [[package]] name = "platformdirs" -version = "3.7.0" +version = "3.8.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.7.0-py3-none-any.whl", hash = "sha256:cfd065ba43133ff103ab3bd10aecb095c2a0035fcd1f07217c9376900d94ba07"}, - {file = "platformdirs-3.7.0.tar.gz", hash = "sha256:87fbf6473e87c078d536980ba970a472422e94f17b752cfad17024c18876d481"}, + {file = "platformdirs-3.8.1-py3-none-any.whl", hash = "sha256:cec7b889196b9144d088e4c57d9ceef7374f6c39694ad1577a0aab50d27ea28c"}, + {file = "platformdirs-3.8.1.tar.gz", hash = "sha256:f87ca4fcff7d2b0f81c6a748a77973d7af0f4d526f98f308477c3c436c74d528"}, ] [package.extras] @@ -877,24 +942,24 @@ virtualenv = ">=20.10.0" [[package]] name = "protobuf" -version = "4.23.3" +version = "4.23.4" description = "" optional = false python-versions = ">=3.7" files = [ - {file = "protobuf-4.23.3-cp310-abi3-win32.whl", hash = "sha256:514b6bbd54a41ca50c86dd5ad6488afe9505901b3557c5e0f7823a0cf67106fb"}, - {file = "protobuf-4.23.3-cp310-abi3-win_amd64.whl", hash = "sha256:cc14358a8742c4e06b1bfe4be1afbdf5c9f6bd094dff3e14edb78a1513893ff5"}, - {file = "protobuf-4.23.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:2991f5e7690dab569f8f81702e6700e7364cc3b5e572725098215d3da5ccc6ac"}, - {file = "protobuf-4.23.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:08fe19d267608d438aa37019236db02b306e33f6b9902c3163838b8e75970223"}, - {file = "protobuf-4.23.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:3b01a5274ac920feb75d0b372d901524f7e3ad39c63b1a2d55043f3887afe0c1"}, - {file = "protobuf-4.23.3-cp37-cp37m-win32.whl", hash = "sha256:aca6e86a08c5c5962f55eac9b5bd6fce6ed98645d77e8bfc2b952ecd4a8e4f6a"}, - {file = "protobuf-4.23.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0149053336a466e3e0b040e54d0b615fc71de86da66791c592cc3c8d18150bf8"}, - {file = "protobuf-4.23.3-cp38-cp38-win32.whl", hash = "sha256:84ea0bd90c2fdd70ddd9f3d3fc0197cc24ecec1345856c2b5ba70e4d99815359"}, - {file = "protobuf-4.23.3-cp38-cp38-win_amd64.whl", hash = "sha256:3bcbeb2bf4bb61fe960dd6e005801a23a43578200ea8ceb726d1f6bd0e562ba1"}, - {file = "protobuf-4.23.3-cp39-cp39-win32.whl", hash = "sha256:5cb9e41188737f321f4fce9a4337bf40a5414b8d03227e1d9fbc59bc3a216e35"}, - {file = "protobuf-4.23.3-cp39-cp39-win_amd64.whl", hash = "sha256:29660574cd769f2324a57fb78127cda59327eb6664381ecfe1c69731b83e8288"}, - {file = "protobuf-4.23.3-py3-none-any.whl", hash = "sha256:447b9786ac8e50ae72cae7a2eec5c5df6a9dbf9aa6f908f1b8bda6032644ea62"}, - {file = "protobuf-4.23.3.tar.gz", hash = "sha256:7a92beb30600332a52cdadbedb40d33fd7c8a0d7f549c440347bc606fb3fe34b"}, + {file = "protobuf-4.23.4-cp310-abi3-win32.whl", hash = "sha256:5fea3c64d41ea5ecf5697b83e41d09b9589e6f20b677ab3c48e5f242d9b7897b"}, + {file = "protobuf-4.23.4-cp310-abi3-win_amd64.whl", hash = "sha256:7b19b6266d92ca6a2a87effa88ecc4af73ebc5cfde194dc737cf8ef23a9a3b12"}, + {file = "protobuf-4.23.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8547bf44fe8cec3c69e3042f5c4fb3e36eb2a7a013bb0a44c018fc1e427aafbd"}, + {file = "protobuf-4.23.4-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:fee88269a090ada09ca63551bf2f573eb2424035bcf2cb1b121895b01a46594a"}, + {file = "protobuf-4.23.4-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:effeac51ab79332d44fba74660d40ae79985901ac21bca408f8dc335a81aa597"}, + {file = "protobuf-4.23.4-cp37-cp37m-win32.whl", hash = "sha256:c3e0939433c40796ca4cfc0fac08af50b00eb66a40bbbc5dee711998fb0bbc1e"}, + {file = "protobuf-4.23.4-cp37-cp37m-win_amd64.whl", hash = "sha256:9053df6df8e5a76c84339ee4a9f5a2661ceee4a0dab019e8663c50ba324208b0"}, + {file = "protobuf-4.23.4-cp38-cp38-win32.whl", hash = "sha256:e1c915778d8ced71e26fcf43c0866d7499891bca14c4368448a82edc61fdbc70"}, + {file = "protobuf-4.23.4-cp38-cp38-win_amd64.whl", hash = "sha256:351cc90f7d10839c480aeb9b870a211e322bf05f6ab3f55fcb2f51331f80a7d2"}, + {file = "protobuf-4.23.4-cp39-cp39-win32.whl", hash = "sha256:6dd9b9940e3f17077e820b75851126615ee38643c2c5332aa7a359988820c720"}, + {file = "protobuf-4.23.4-cp39-cp39-win_amd64.whl", hash = "sha256:0a5759f5696895de8cc913f084e27fd4125e8fb0914bb729a17816a33819f474"}, + {file = "protobuf-4.23.4-py3-none-any.whl", hash = "sha256:e9d0be5bf34b275b9f87ba7407796556abeeba635455d036c7351f7c183ef8ff"}, + {file = "protobuf-4.23.4.tar.gz", hash = "sha256:ccd9430c0719dce806b93f89c91de7977304729e55377f872a92465d548329a9"}, ] [[package]] @@ -924,13 +989,13 @@ pyasn1 = ">=0.4.6,<0.6.0" [[package]] name = "pytest" -version = "7.3.2" +version = "7.4.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.3.2-py3-none-any.whl", hash = "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295"}, - {file = "pytest-7.3.2.tar.gz", hash = "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b"}, + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, ] [package.dependencies] @@ -1035,6 +1100,21 @@ files = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] +[[package]] +name = "referencing" +version = "0.29.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.29.1-py3-none-any.whl", hash = "sha256:d3c8f323ee1480095da44d55917cfb8278d73d6b4d5f677e3e40eb21314ac67f"}, + {file = "referencing-0.29.1.tar.gz", hash = "sha256:90cb53782d550ba28d2166ef3f55731f38397def8832baac5d45235f1995e35e"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + [[package]] name = "requests" version = "2.31.0" @@ -1074,6 +1154,126 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "rpds-py" +version = "0.8.10" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rpds_py-0.8.10-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:93d06cccae15b3836247319eee7b6f1fdcd6c10dabb4e6d350d27bd0bdca2711"}, + {file = "rpds_py-0.8.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3816a890a6a9e9f1de250afa12ca71c9a7a62f2b715a29af6aaee3aea112c181"}, + {file = "rpds_py-0.8.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7c6304b894546b5a6bdc0fe15761fa53fe87d28527a7142dae8de3c663853e1"}, + {file = "rpds_py-0.8.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad3bfb44c8840fb4be719dc58e229f435e227fbfbe133dc33f34981ff622a8f8"}, + {file = "rpds_py-0.8.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14f1c356712f66653b777ecd8819804781b23dbbac4eade4366b94944c9e78ad"}, + {file = "rpds_py-0.8.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82bb361cae4d0a627006dadd69dc2f36b7ad5dc1367af9d02e296ec565248b5b"}, + {file = "rpds_py-0.8.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2e3c4f2a8e3da47f850d7ea0d7d56720f0f091d66add889056098c4b2fd576c"}, + {file = "rpds_py-0.8.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15a90d0ac11b4499171067ae40a220d1ca3cb685ec0acc356d8f3800e07e4cb8"}, + {file = "rpds_py-0.8.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:70bb9c8004b97b4ef7ae56a2aa56dfaa74734a0987c78e7e85f00004ab9bf2d0"}, + {file = "rpds_py-0.8.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d64f9f88d5203274a002b54442cafc9c7a1abff2a238f3e767b70aadf919b451"}, + {file = "rpds_py-0.8.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ccbbd276642788c4376fbe8d4e6c50f0fb4972ce09ecb051509062915891cbf0"}, + {file = "rpds_py-0.8.10-cp310-none-win32.whl", hash = "sha256:fafc0049add8043ad07ab5382ee80d80ed7e3699847f26c9a5cf4d3714d96a84"}, + {file = "rpds_py-0.8.10-cp310-none-win_amd64.whl", hash = "sha256:915031002c86a5add7c6fd4beb601b2415e8a1c956590a5f91d825858e92fe6e"}, + {file = "rpds_py-0.8.10-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:84eb541a44f7a18f07a6bfc48b95240739e93defe1fdfb4f2a295f37837945d7"}, + {file = "rpds_py-0.8.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f59996d0550894affaad8743e97b9b9c98f638b221fac12909210ec3d9294786"}, + {file = "rpds_py-0.8.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9adb5664b78fcfcd830000416c8cc69853ef43cb084d645b3f1f0296edd9bae"}, + {file = "rpds_py-0.8.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f96f3f98fbff7af29e9edf9a6584f3c1382e7788783d07ba3721790625caa43e"}, + {file = "rpds_py-0.8.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:376b8de737401050bd12810003d207e824380be58810c031f10ec563ff6aef3d"}, + {file = "rpds_py-0.8.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d1c2bc319428d50b3e0fa6b673ab8cc7fa2755a92898db3a594cbc4eeb6d1f7"}, + {file = "rpds_py-0.8.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73a1e48430f418f0ac3dfd87860e4cc0d33ad6c0f589099a298cb53724db1169"}, + {file = "rpds_py-0.8.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134ec8f14ca7dbc6d9ae34dac632cdd60939fe3734b5d287a69683c037c51acb"}, + {file = "rpds_py-0.8.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4b519bac7c09444dd85280fd60f28c6dde4389c88dddf4279ba9b630aca3bbbe"}, + {file = "rpds_py-0.8.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9cd57981d9fab04fc74438d82460f057a2419974d69a96b06a440822d693b3c0"}, + {file = "rpds_py-0.8.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:69d089c026f6a8b9d64a06ff67dc3be196707b699d7f6ca930c25f00cf5e30d8"}, + {file = "rpds_py-0.8.10-cp311-none-win32.whl", hash = "sha256:220bdcad2d2936f674650d304e20ac480a3ce88a40fe56cd084b5780f1d104d9"}, + {file = "rpds_py-0.8.10-cp311-none-win_amd64.whl", hash = "sha256:6c6a0225b8501d881b32ebf3f5807a08ad3685b5eb5f0a6bfffd3a6e039b2055"}, + {file = "rpds_py-0.8.10-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:e3d0cd3dff0e7638a7b5390f3a53057c4e347f4ef122ee84ed93fc2fb7ea4aa2"}, + {file = "rpds_py-0.8.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d77dff3a5aa5eedcc3da0ebd10ff8e4969bc9541aa3333a8d41715b429e99f47"}, + {file = "rpds_py-0.8.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41c89a366eae49ad9e65ed443a8f94aee762931a1e3723749d72aeac80f5ef2f"}, + {file = "rpds_py-0.8.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3793c21494bad1373da517001d0849eea322e9a049a0e4789e50d8d1329df8e7"}, + {file = "rpds_py-0.8.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:805a5f3f05d186c5d50de2e26f765ba7896d0cc1ac5b14ffc36fae36df5d2f10"}, + {file = "rpds_py-0.8.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b01b39ad5411563031ea3977bbbc7324d82b088e802339e6296f082f78f6115c"}, + {file = "rpds_py-0.8.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f1e860be21f3e83011116a65e7310486300e08d9a3028e73e8d13bb6c77292"}, + {file = "rpds_py-0.8.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a13c8e56c46474cd5958d525ce6a9996727a83d9335684e41f5192c83deb6c58"}, + {file = "rpds_py-0.8.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:93d99f957a300d7a4ced41615c45aeb0343bb8f067c42b770b505de67a132346"}, + {file = "rpds_py-0.8.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:148b0b38d719c0760e31ce9285a9872972bdd7774969a4154f40c980e5beaca7"}, + {file = "rpds_py-0.8.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3cc5e5b5514796f45f03a568981971b12a3570f3de2e76114f7dc18d4b60a3c4"}, + {file = "rpds_py-0.8.10-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:e8e24b210a4deb5a7744971f8f77393005bae7f873568e37dfd9effe808be7f7"}, + {file = "rpds_py-0.8.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b41941583adce4242af003d2a8337b066ba6148ca435f295f31ac6d9e4ea2722"}, + {file = "rpds_py-0.8.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c490204e16bca4f835dba8467869fe7295cdeaa096e4c5a7af97f3454a97991"}, + {file = "rpds_py-0.8.10-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee45cd1d84beed6cbebc839fd85c2e70a3a1325c8cfd16b62c96e2ffb565eca"}, + {file = "rpds_py-0.8.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a8ca409f1252e1220bf09c57290b76cae2f14723746215a1e0506472ebd7bdf"}, + {file = "rpds_py-0.8.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96b293c0498c70162effb13100624c5863797d99df75f2f647438bd10cbf73e4"}, + {file = "rpds_py-0.8.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4627520a02fccbd324b33c7a83e5d7906ec746e1083a9ac93c41ac7d15548c7"}, + {file = "rpds_py-0.8.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e39d7ab0c18ac99955b36cd19f43926450baba21e3250f053e0704d6ffd76873"}, + {file = "rpds_py-0.8.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ba9f1d1ebe4b63801977cec7401f2d41e888128ae40b5441270d43140efcad52"}, + {file = "rpds_py-0.8.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:802f42200d8caf7f25bbb2a6464cbd83e69d600151b7e3b49f49a47fa56b0a38"}, + {file = "rpds_py-0.8.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:d19db6ba816e7f59fc806c690918da80a7d186f00247048cd833acdab9b4847b"}, + {file = "rpds_py-0.8.10-cp38-none-win32.whl", hash = "sha256:7947e6e2c2ad68b1c12ee797d15e5f8d0db36331200b0346871492784083b0c6"}, + {file = "rpds_py-0.8.10-cp38-none-win_amd64.whl", hash = "sha256:fa326b3505d5784436d9433b7980171ab2375535d93dd63fbcd20af2b5ca1bb6"}, + {file = "rpds_py-0.8.10-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7b38a9ac96eeb6613e7f312cd0014de64c3f07000e8bf0004ad6ec153bac46f8"}, + {file = "rpds_py-0.8.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c4d42e83ddbf3445e6514f0aff96dca511421ed0392d9977d3990d9f1ba6753c"}, + {file = "rpds_py-0.8.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b21575031478609db6dbd1f0465e739fe0e7f424a8e7e87610a6c7f68b4eb16"}, + {file = "rpds_py-0.8.10-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:574868858a7ff6011192c023a5289158ed20e3f3b94b54f97210a773f2f22921"}, + {file = "rpds_py-0.8.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae40f4a70a1f40939d66ecbaf8e7edc144fded190c4a45898a8cfe19d8fc85ea"}, + {file = "rpds_py-0.8.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37f7ee4dc86db7af3bac6d2a2cedbecb8e57ce4ed081f6464510e537589f8b1e"}, + {file = "rpds_py-0.8.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:695f642a3a5dbd4ad2ffbbacf784716ecd87f1b7a460843b9ddf965ccaeafff4"}, + {file = "rpds_py-0.8.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f43ab4cb04bde6109eb2555528a64dfd8a265cc6a9920a67dcbde13ef53a46c8"}, + {file = "rpds_py-0.8.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a11ab0d97be374efd04f640c04fe5c2d3dabc6dfb998954ea946ee3aec97056d"}, + {file = "rpds_py-0.8.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:92cf5b3ee60eef41f41e1a2cabca466846fb22f37fc580ffbcb934d1bcab225a"}, + {file = "rpds_py-0.8.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ceaac0c603bf5ac2f505a78b2dcab78d3e6b706be6596c8364b64cc613d208d2"}, + {file = "rpds_py-0.8.10-cp39-none-win32.whl", hash = "sha256:dd4f16e57c12c0ae17606c53d1b57d8d1c8792efe3f065a37cb3341340599d49"}, + {file = "rpds_py-0.8.10-cp39-none-win_amd64.whl", hash = "sha256:c03a435d26c3999c2a8642cecad5d1c4d10c961817536af52035f6f4ee2f5dd0"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:0da53292edafecba5e1d8c1218f99babf2ed0bf1c791d83c0ab5c29b57223068"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7d20a8ed227683401cc508e7be58cba90cc97f784ea8b039c8cd01111e6043e0"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97cab733d303252f7c2f7052bf021a3469d764fc2b65e6dbef5af3cbf89d4892"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c398fda6df361a30935ab4c4bccb7f7a3daef2964ca237f607c90e9f3fdf66f"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2eb4b08c45f8f8d8254cdbfacd3fc5d6b415d64487fb30d7380b0d0569837bf1"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7dfb1cbb895810fa2b892b68153c17716c6abaa22c7dc2b2f6dcf3364932a1c"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c92b74e8bf6f53a6f4995fd52f4bd510c12f103ee62c99e22bc9e05d45583c"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e9c0683cb35a9b5881b41bc01d5568ffc667910d9dbc632a1fba4e7d59e98773"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:0eeb2731708207d0fe2619afe6c4dc8cb9798f7de052da891de5f19c0006c315"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:7495010b658ec5b52835f21d8c8b1a7e52e194c50f095d4223c0b96c3da704b1"}, + {file = "rpds_py-0.8.10-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:c72ebc22e70e04126158c46ba56b85372bc4d54d00d296be060b0db1671638a4"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:2cd3045e7f6375dda64ed7db1c5136826facb0159ea982f77d9cf6125025bd34"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:2418cf17d653d24ffb8b75e81f9f60b7ba1b009a23298a433a4720b2a0a17017"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a2edf8173ac0c7a19da21bc68818be1321998528b5e3f748d6ee90c0ba2a1fd"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f29b8c55fd3a2bc48e485e37c4e2df3317f43b5cc6c4b6631c33726f52ffbb3"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a7d20c1cf8d7b3960c5072c265ec47b3f72a0c608a9a6ee0103189b4f28d531"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:521fc8861a86ae54359edf53a15a05fabc10593cea7b3357574132f8427a5e5a"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5c191713e98e7c28800233f039a32a42c1a4f9a001a8a0f2448b07391881036"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:083df0fafe199371206111583c686c985dddaf95ab3ee8e7b24f1fda54515d09"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ed41f3f49507936a6fe7003985ea2574daccfef999775525d79eb67344e23767"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:2614c2732bf45de5c7f9e9e54e18bc78693fa2f635ae58d2895b7965e470378c"}, + {file = "rpds_py-0.8.10-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:c60528671d9d467009a6ec284582179f6b88651e83367d0ab54cb739021cd7de"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ee744fca8d1ea822480a2a4e7c5f2e1950745477143668f0b523769426060f29"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a38b9f526d0d6cbdaa37808c400e3d9f9473ac4ff64d33d9163fd05d243dbd9b"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60e0e86e870350e03b3e25f9b1dd2c6cc72d2b5f24e070249418320a6f9097b7"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f53f55a8852f0e49b0fc76f2412045d6ad9d5772251dea8f55ea45021616e7d5"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c493365d3fad241d52f096e4995475a60a80f4eba4d3ff89b713bc65c2ca9615"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300eb606e6b94a7a26f11c8cc8ee59e295c6649bd927f91e1dbd37a4c89430b6"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a665f6f1a87614d1c3039baf44109094926dedf785e346d8b0a728e9cabd27a"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:927d784648211447201d4c6f1babddb7971abad922b32257ab74de2f2750fad0"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c200b30dd573afa83847bed7e3041aa36a8145221bf0cfdfaa62d974d720805c"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:08166467258fd0240a1256fce272f689f2360227ee41c72aeea103e9e4f63d2b"}, + {file = "rpds_py-0.8.10-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:996cc95830de9bc22b183661d95559ec6b3cd900ad7bc9154c4cbf5be0c9b734"}, + {file = "rpds_py-0.8.10.tar.gz", hash = "sha256:13e643ce8ad502a0263397362fb887594b49cf84bf518d6038c16f235f2bcea4"}, +] + [[package]] name = "rsa" version = "4.9" @@ -1358,13 +1558,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.6.3" +version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, - {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] [[package]] @@ -1537,4 +1737,4 @@ scikit-learn = ["scikit-learn"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "1894a3e36391284c934f02990ff7975ee3e69256dfebae6a80a102b29bb94c57" +content-hash = "fee4143f466c3fcbd38a5ca7ea03d6560d907b86a220fa13db568d7a0fdc259c" diff --git a/test-engine-core-modules/pyproject.toml b/test-engine-core-modules/pyproject.toml index 6c92acd98..e73aafa64 100644 --- a/test-engine-core-modules/pyproject.toml +++ b/test-engine-core-modules/pyproject.toml @@ -18,6 +18,7 @@ xgboost = "1.7.4" tensorflow = "2.12.0" pandas = "1.5.3" scipy = "1.9.3" +openapi-schema-validator = "0.6.0" [tool.poetry.group.dev.dependencies] diff --git a/test-engine-core-modules/src/apiconnector/__init__.py b/test-engine-core-modules/src/apiconnector/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test-engine-core-modules/src/apiconnector/__main__.py b/test-engine-core-modules/src/apiconnector/__main__.py new file mode 100644 index 000000000..f391845e5 --- /dev/null +++ b/test-engine-core-modules/src/apiconnector/__main__.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from module_tests.plugin_test import PluginTest + +if __name__ == "__main__": + discover_path = Path.cwd().parent + api_schema_path = str( + discover_path / "apiconnector/user_defined_files/test_api_schema.json" + ) + api_config_path = str( + discover_path / "apiconnector/user_defined_files/test_api_config.json" + ) + + # ================================================================================= + # NOTE: Do not modify the code below + # ================================================================================= + # Perform Plugin Testing + try: + # Create an instance of PluginTest with defined paths and arguments and Run. + plugin_test = PluginTest(api_schema_path, api_config_path, discover_path) + plugin_test.run() + + except Exception as exception: + print(f"Exception caught while running the plugin test: {str(exception)}") diff --git a/test-engine-core-modules/src/apiconnector/apiconnector.py b/test-engine-core-modules/src/apiconnector/apiconnector.py new file mode 100644 index 000000000..f8a4d4bb6 --- /dev/null +++ b/test-engine-core-modules/src/apiconnector/apiconnector.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Tuple, Union + +from openapi_schema_validator import OAS30Validator, validate +from test_engine_core.interfaces.imodel import IModel +from test_engine_core.plugins.enums.model_plugin_type import ModelPluginType +from test_engine_core.plugins.enums.plugin_type import PluginType +from test_engine_core.plugins.metadata.plugin_metadata import PluginMetadata + +# from requests import Session, session +# from requests.adapters import HTTPAdapter, Retry + + +# NOTE: Do not change the class name, else the plugin cannot be read by the system +class Plugin(IModel): + """ + The Plugin(apiconnector) class specifies methods on + handling methods in performing identifying, validating, predicting, scoring. + """ + + # Some information on plugin + _model: Any = None + _model_algorithm: str = "" + _supported_algorithms: List = [""] + _name: str = "apiconnector" + _description: str = ( + "apiconnector supports performing api calls to external model servers" + ) + _version: str = "0.9.0" + _metadata: PluginMetadata = PluginMetadata(_name, _description, _version) + _plugin_type: PluginType = PluginType.MODEL + _model_plugin_type: ModelPluginType = ModelPluginType.API + + @staticmethod + def get_metadata() -> PluginMetadata: + """ + A method to return the metadata for this plugin + + Returns: + PluginMetadata: Metadata of this plugin + """ + return Plugin._metadata + + @staticmethod + def get_plugin_type() -> PluginType: + """ + A method to return the type for this plugin + + Returns: + PluginType: Type of this plugin + """ + return Plugin._plugin_type + + @staticmethod + def get_model_plugin_type() -> ModelPluginType: + """ + A method to return ModelPluginType + + Returns: + ModelPluginType: Model Plugin Type + """ + return Plugin._model_plugin_type + + def __init__(self, api_schema: Dict, api_config: Dict) -> None: + # Configuration + self._is_setup_completed = False + if api_schema and api_config: + self._api_schema = api_schema + self._api_config = api_config + else: + self._api_schema: Dict = dict() + self._api_config: Dict = dict() + + # # API variables + # self._openapi3_inst = None + # self._default_api_retries: int = 3 + # self._session: Union[Session, None] = None + # self._additional_headers: Dict = dict() + # self._auth_info: Dict = dict() + # # (0s, 2s, 4s) + # # Formula: {backoff factor} * (2 ** ({number of total retries} - 1)) + # self._default_api_backoff: float = 1.0 + # self._default_api_timeout: float = 5.0 # seconds + # self._default_api_status_code: list = [429, 500, 502, 503, 504] + # self._default_api_allowed_methods: list = ["GET", "POST"] + + def cleanup(self) -> None: + """ + A method to clean-up objects + """ + if self._session is not None: + self._session.close() + else: + pass # pragma: no cover + + def setup(self) -> Tuple[bool, str]: + """ + A method to perform setup + + Returns: + Tuple[bool, str]: Returns bool to indicate success, str will indicate the + error message if failed. + """ + try: + print("HelloWorld") + # # Search for the first api and http method. + # # Set the prediction operationId + # path_to_be_updated = self._api_schema["paths"] + # if len(path_to_be_updated) > 0: + # first_api = list(path_to_be_updated.items())[0] + # first_api_value = first_api[1] + # if len(first_api_value) > 0: + # first_api_http = list(first_api_value.items())[0] + # first_api_http_value = first_api_http[1] + # first_api_http_value.update({"operationId": "predict_api"}) + + # # Parse the openapi schema + # self._openapi3_inst = OpenAPI(self._api_schema, validate=True) + # self._setup_authentication() + + # # Prepare headers information for sending query + # # Convert headers object into key-attribute mapping in dict + # if "headers" in self._api_config.keys(): + # self._additional_headers = self._api_config["headers"] + # else: + # self._additional_headers = dict() + + # # Setup session retry strategy + # # It will perform 3 times retries and have default backoff time for status_forcelist error code + # # It will perform for methods in whitelist + # retry_strategy = Retry( + # total=self._default_api_retries, + # backoff_factor=self._default_api_backoff, + # status_forcelist=self._default_api_status_code, + # allowed_methods=self._default_api_allowed_methods, + # ) + # adapter = HTTPAdapter(max_retries=retry_strategy) + # self._session = session() + # self._session.verify = False + # self._session.mount("https://", adapter) + # self._session.mount("http://", adapter) + + # Setup completed + self._is_setup_completed = True + return True, "" + + except Exception as error: + # Error setting up api connection + return False, str(error) + + def get_model(self) -> Any: + """ + A method to return the model + + Returns: + Any: Model + """ + if self._model: + return self._model + else: + return None + + def get_model_algorithm(self) -> str: + """ + A method to return the model algorithm. + Either one of the supported algorithms or "" + + Returns: + str: model algorithm name if supported or "" if not supported + """ + return self._model_algorithm + + def is_supported(self) -> bool: + """ + A method to check whether the model is being identified correctly + and is supported + + Returns: + bool: True if is an instance of model and is supported + """ + try: + validate(self._api_config, self._api_schema, cls=OAS30Validator) + return True + except Exception as error: + return False, str(error) + + def predict(self, data: Any, *args) -> Any: + """ + A method to perform prediction using the model + + Args: + data (Any): data to be predicted by the model + + Returns: + Any: predicted result + """ + pass + # try: + # return self._model.predict(data) + # except Exception: + # raise + + def predict_proba(self, data: Any, *args) -> Any: + """ + A method to perform prediction probability using the model + + Args: + data (Any): data to be predicted by the model + + Returns: + Any: predicted result + """ + pass + # try: + # return self._model.predict_proba(data) + # except Exception: + # raise + + def score(self, data: Any, y_true: Any) -> Any: + """ + A method to perform scoring using the model + + Args: + data (Any): data to be scored with y_true + y_true (Any): ground truth + + Returns: + Any: score result + """ + raise RuntimeError("ApiConnector does not support score method") + + # def _identify_model_algorithm(self, model: Any) -> Tuple[bool, str]: + # """ + # A helper method to identify the model algorithm whether it is being supported + + # Args: + # model (Any): the model to be checked against the supported model list + + # Returns: + # Tuple[bool, str]: true if model is supported, str will store the support + # algo name + # """ + # model_algorithm = "" + # is_success = False + + # module_type_name = f"{type(model).__module__}.{type(model).__name__}" + # for supported_algo in self._supported_algorithms: + # if supported_algo == module_type_name: + # model_algorithm = supported_algo + # is_success = True + + # return is_success, model_algorithm diff --git a/test-engine-core-modules/src/apiconnector/module_tests/__init__.py b/test-engine-core-modules/src/apiconnector/module_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py b/test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py new file mode 100644 index 000000000..0a9622902 --- /dev/null +++ b/test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py @@ -0,0 +1,183 @@ +import sys +from pathlib import Path +from typing import Tuple, Union + +from test_engine_core.interfaces.imodel import IModel +from test_engine_core.interfaces.iserializer import ISerializer +from test_engine_core.plugins.enums.model_mode_type import ModelModeType +from test_engine_core.plugins.enums.model_plugin_type import ModelPluginType +from test_engine_core.plugins.enums.plugin_type import PluginType +from test_engine_core.plugins.plugins_manager import PluginManager +from test_engine_core.utils.json_utils import load_schema_file +from test_engine_core.utils.time import time_class_method + + +# ===================================================================================== +# NOTE: Do not modify this file unless you know what you are doing. +# ===================================================================================== +class PluginTest: + """ + The PluginTest class specifies methods in supporting testing for the plugin. + """ + + def __init__(self, api_schema_path: str, api_config_path: str, discover_path: Path): + # Other variables + self._base_path: Path = discover_path + + # Store the input arguments as private vars + self._api_schema = load_schema_file(str(self._base_path / api_schema_path)) + self._api_config = load_schema_file(str(self._base_path / api_config_path)) + + # Default for instances + self._model_instance: Union[None, IModel] = None + self._model_serializer_instance: Union[None, ISerializer] = None + + self._expected_model_algorithm = "" + self._expected_model_plugin_type = ModelPluginType.API + + @time_class_method + def run(self) -> None: + """ + A function to run the plugin test with the provided arguments. + """ + try: + error_count = 0 + error_message = "" + + # Load all the core plugins and the model plugin + PluginManager.discover(str(self._base_path)) + + # # Get the model instance + # ( + # self._model_instance, + # self._model_serializer_instance, + # error_message, + # ) = PluginManager.get_instance( + # PluginType.MODEL, + # **{ + # "mode": ModelModeType.API, + # "api_schema": self._api_schema, + # "api_config": self._api_config, + # } + # ) + + print("Helloworld") + + # # Perform model instance setup + # is_success, error_messages = self._model_instance.setup() + # if not is_success: + # raise RuntimeError( + # f"Failed to perform model instance setup: {error_messages}" + # ) + + # # Run different tests on the model instance + # test_methods = [ + # self._validate_metadata, + # self._validate_plugin_type, + # self._validate_model_supported, + # ] + + # for method in test_methods: + # tmp_count, tmp_error_msg = method() + # error_count += tmp_count + # error_message += tmp_error_msg + + # # Perform cleanup + # self._model_instance.cleanup() + + # if error_count > 0: + # print(f"Errors found while running tests. {error_message}") + # sys.exit(-1) + # else: + # print("No errors found. Test completed successfully.") + # sys.exit(0) + + except Exception as error: + # Print and exit with error + print(f"Exception found while running tests. {str(error)}") + sys.exit(-1) + + def _validate_metadata(self) -> Tuple[int, str]: + """ + A helper method to validate metadata + + Returns: + Tuple[int, str]: Returns error count and error messages + """ + error_count = 0 + error_message = "" + + metadata = self._model_instance.get_metadata() + if ( + metadata.name == "apiconnector" + and metadata.description + == "apiconnector supports performing api calls to external model servers" + and metadata.version == "0.9.0" + ): + # Metadata is correct + pass + else: + # Metadata is incorrect + error_count += 1 + error_message += "Incorrect metadata;" + + return error_count, error_message + + def _validate_plugin_type(self) -> Tuple[int, str]: + """ + A helper method to validate plugin type + + Returns: + Tuple[int, str]: Returns error count and error messages + """ + error_count = 0 + error_message = "" + + if self._model_instance.get_plugin_type() is PluginType.MODEL: + # PluginType is correct + pass + else: + # PluginType is wrong + error_count += 1 + error_message += "Incorrect plugin type;" + + if ( + self._model_instance.get_model_plugin_type() + is self._expected_model_plugin_type + ): + # Model PluginType is correct + pass + else: + # Model PluginType is incorrect + error_count += 1 + error_message += "Incorrect model plugin type;" + + if self._model_instance.get_model_algorithm() == self._expected_model_algorithm: + # Model Algorithm is correct + pass + else: + # Model Algorithm is incorrect + error_count += 1 + error_message += "Incorrect model algorithm;" + + return error_count, error_message + + def _validate_model_supported(self) -> Tuple[int, str]: + """ + A helper method to validate model supported + + Returns: + Tuple[int, str]: Returns error count and error messages + """ + error_count = 0 + error_message = "" + + if self._model_instance.is_supported(): + # Model is supported + pass + else: + # Model is not supported + error_count += 1 + error_message += "Model not supported;" + + return error_count, error_message diff --git a/test-engine-core-modules/src/apiconnector/user_defined_files/test_api_config.json b/test-engine-core-modules/src/apiconnector/user_defined_files/test_api_config.json new file mode 100644 index 000000000..2b4ce3e18 --- /dev/null +++ b/test-engine-core-modules/src/apiconnector/user_defined_files/test_api_config.json @@ -0,0 +1,26 @@ +{ + "responseBody": { + "field": "data", + "type": "integer" + }, + "authentication": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYyMmY4MTJiNmJlM2IzMjEyMTQzMjBjZiIsImlhdCI6MTY2MDE5Nzg3MCwiZXhwIjoxNjYyNzg5ODcwfQ.cebsoHVMzV4GGwX-QjHFc5CcTkEy7jLQQLaaHlvN2JU" + }, + "headers": { + "foo": "bar" + }, + "requestBody": { + "age": "age_cat_cat", + "gender": "sex_code", + "race": "race_code", + "count": "priors_count", + "charge": "c_charge_degree_cat" + }, + "requestOptions": { + "rateLimit": -1, + "batchStrategy": "none", + "batchLimit": -1, + "maxConnections": -1, + "requestTimeout": 3000 + } + } \ No newline at end of file diff --git a/test-engine-core-modules/src/apiconnector/user_defined_files/test_api_schema.json b/test-engine-core-modules/src/apiconnector/user_defined_files/test_api_schema.json new file mode 100644 index 000000000..08827fa8a --- /dev/null +++ b/test-engine-core-modules/src/apiconnector/user_defined_files/test_api_schema.json @@ -0,0 +1,86 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "API-Based Testing", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://localhost:5000" + } + ], + "components": { + "securitySchemes": { + "myAuth": { + "type": "http", + "scheme": "bearer" + } + } + }, + "paths": { + "/predict/tc007": { + "post": { + "parameters": [ + { + "in": "header", + "name": "foo", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "security": [ + { + "myAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": [ + "age", + "gender", + "race", + "count", + "charge" + ], + "properties": { + "age": { + "type": "integer" + }, + "gender": { + "type": "integer" + }, + "race": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "charge": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } \ No newline at end of file diff --git a/test-engine-core/dist/test_engine_core-0.9.0.tar.gz b/test-engine-core/dist/test_engine_core-0.9.0.tar.gz index f8f93ec87c6bc72555c70d2dfc1acfde9bd0f3b7..50bbb43ccc3d543ab1b215c5b06eda89fccd5bf2 100644 GIT binary patch delta 49827 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⁢GMyWvSP=5R>~#ijonfsJ|25iU~4$ajj6w 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 diff --git a/test-engine-core/test_engine_core/plugins/enums/model_plugin_type.py b/test-engine-core/test_engine_core/plugins/enums/model_plugin_type.py index 672df73e5..25d99d1a8 100644 --- a/test-engine-core/test_engine_core/plugins/enums/model_plugin_type.py +++ b/test-engine-core/test_engine_core/plugins/enums/model_plugin_type.py @@ -10,3 +10,4 @@ class ModelPluginType(Enum): TENSORFLOW = 2 XGBOOST = 3 LIGHTGBM = 4 + API = 5 diff --git a/test-engine-core/test_engine_core/plugins/plugins_manager.py b/test-engine-core/test_engine_core/plugins/plugins_manager.py index 3ef548f2f..ca2df3d99 100644 --- a/test-engine-core/test_engine_core/plugins/plugins_manager.py +++ b/test-engine-core/test_engine_core/plugins/plugins_manager.py @@ -46,6 +46,7 @@ class PluginManager: ModelPluginType.XGBOOST, ModelPluginType.SKLEARN, ModelPluginType.TENSORFLOW, + ModelPluginType.API, ] _serializer_priority_list: List = [ SerializerPluginType.PICKLE, From 9532f2be442888babf53472d38d3e0767eca7554 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Wed, 12 Jul 2023 11:16:24 +0800 Subject: [PATCH 029/176] add name field to requestBody type, to allow user to enter property name for arrays definition --- .../graphql/modules/assets/modelapi.graphql | 7 ++++--- ai-verify-apigw/models/model.model.mjs | 11 ++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql index 9c7bf4f9f..fb9bfa403 100644 --- a/ai-verify-apigw/graphql/modules/assets/modelapi.graphql +++ b/ai-verify-apigw/graphql/modules/assets/modelapi.graphql @@ -44,7 +44,7 @@ type OpenAPIParametersPathType { type OpenAPIParametersQueryType { mediaType: OpenAPIMediaType! # default none - name: String # name of query if mediaType !== 'none' + name: String @constraint(minLength: 1, maxLength: 128) # name of query if mediaType !== 'none' isArray: Boolean! # indicate if is array, default false, note that cannot be array if mediaType == 'none' maxItems: Int @constraint(min: 1) # max array items if array queryParams: [QueryAPIParamsType] @@ -63,6 +63,7 @@ type OpenAPIRequestBodyPropertyType { type OpenAPIRequestBodyType { mediaType: OpenAPIMediaType! isArray: Boolean! # indicate if is array, default false + name: String @constraint(minLength: 1, maxLength: 128) # name of payload property when array maxItems: Int @constraint(min: 1) # max array items if array properties: [OpenAPIRequestBodyPropertyType]! } @@ -125,7 +126,7 @@ input OpenAPIParametersPathInput { input OpenAPIParametersQueryInput { mediaType: OpenAPIMediaType! # default none - name: String # name of query if mediaType !== 'none' + name: String @constraint(minLength: 1, maxLength: 128) # name of query if mediaType !== 'none' isArray: Boolean! # indicate if is array, default false, note that cannot be array if mediaType == 'none' maxItems: Int @constraint(min: 1) # max array items if array queryParams: [OpenAPIQueryParamsInput] @@ -143,7 +144,7 @@ input OpenAPIRequestBodyPropertyInput { input OpenAPIRequestBodyInput { mediaType: OpenAPIMediaType! - name: String # name of payload property when array + name: String @constraint(minLength: 1, maxLength: 128) # name of payload property when array isArray: Boolean! # indicate if is array, default false maxItems: Int @constraint(min: 1) # max array items if array properties: [OpenAPIRequestBodyPropertyInput] diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 56966dc63..2d4406d6f 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -60,6 +60,7 @@ const modelAPIRequestBodySchema = new Schema({ enum: MEDIA_TYPES, }, isArray: { type: Boolean, required: true, default: false }, + name: { type: String }, maxItems: { type: Number }, // max array items if itemType == 'array' properties: [ { @@ -433,10 +434,18 @@ function _exportModelAPI(modelAPI) { properties, }; if (modelAPI.requestBody.isArray) { - const schema = { + let schema = { type: "array", items: objectDefinition, }; + if (modelAPI.requestBody.name && modelAPI.requestBody.name.length > 0) { + schema = { + type: "object", + properties: { + [modelAPI.requestBody.name]: schema + } + } + } if (modelAPI.requestBody.maxItems) { schema.maxItems = modelAPI.requestBody.maxItems; } From 70b96f8a52e912bdf1d4adc1c97a1f52c42df8e2 Mon Sep 17 00:00:00 2001 From: Lionel Teo <93119265+imda-lionelteo@users.noreply.github.com> Date: Wed, 12 Jul 2023 14:49:40 +0000 Subject: [PATCH 030/176] update logic to model manager and plugins manager --- .../src/apiconnector/apiconnector.py | 40 ++++++++++++- .../test_engine_core/plugins/model_manager.py | 60 +++++++++++++++++++ .../plugins/plugins_manager.py | 21 ++++++- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/test-engine-core-modules/src/apiconnector/apiconnector.py b/test-engine-core-modules/src/apiconnector/apiconnector.py index f8a4d4bb6..9f6621e2a 100644 --- a/test-engine-core-modules/src/apiconnector/apiconnector.py +++ b/test-engine-core-modules/src/apiconnector/apiconnector.py @@ -1,7 +1,10 @@ from __future__ import annotations -from typing import Any, Dict, List, Tuple, Union +import pathlib +from typing import Any, Dict, List, Tuple +import httpx +from aiopenapi3 import FileSystemLoader, OpenAPI from openapi_schema_validator import OAS30Validator, validate from test_engine_core.interfaces.imodel import IModel from test_engine_core.plugins.enums.model_plugin_type import ModelPluginType @@ -85,6 +88,18 @@ def __init__(self, api_schema: Dict, api_config: Dict) -> None: # self._default_api_status_code: list = [429, 500, 502, 503, 504] # self._default_api_allowed_methods: list = ["GET", "POST"] + @staticmethod + def session_factory(*args, **kwargs) -> httpx.AsyncClient: + """ + A factory that generates async client + + Returns: + httpx.AsyncClient: Returns an httpx AsyncClient with additional settings + """ + kwargs["verify"] = False + kwargs["timeout"] = 5.0 # seconds + return httpx.AsyncClient(*args, **kwargs) + def cleanup(self) -> None: """ A method to clean-up objects @@ -94,7 +109,7 @@ def cleanup(self) -> None: else: pass # pragma: no cover - def setup(self) -> Tuple[bool, str]: + async def setup(self) -> Tuple[bool, str]: """ A method to perform setup @@ -103,7 +118,26 @@ def setup(self) -> Tuple[bool, str]: error message if failed. """ try: - print("HelloWorld") + token = "" + api = OpenAPI.load_file( + url="", + path=pathlib.Path("test_api_config.json"), + session_factory=Plugin.session_factory, + loader=FileSystemLoader( + pathlib.Path( + "/workspaces/aiverify-backend-dev/aiverify/test-engine-core-modules/src/apiconnector" + ) + ), + ) + api.authenticate(myAuth=f"token {token}") + predict_request = api.createRequest(("/predict/tc007", "post")) + body = predict_request.data.get_type().parse_obj( + {"age": 14, "gender": 13, "race": 13, "count": 13, "charge": 13} + ) + headers, data, result = await predict_request.request( + parameters={"foo": "bar"}, data=body + ) + print(result.text, result.status_code, result.content) # # Search for the first api and http method. # # Set the prediction operationId # path_to_be_updated = self._api_schema["paths"] diff --git a/test-engine-core/test_engine_core/plugins/model_manager.py b/test-engine-core/test_engine_core/plugins/model_manager.py index f424b1b29..4b9d7f112 100644 --- a/test-engine-core/test_engine_core/plugins/model_manager.py +++ b/test-engine-core/test_engine_core/plugins/model_manager.py @@ -26,6 +26,66 @@ def set_logger(logger: logging.Logger) -> None: if isinstance(logger, logging.Logger): ModelManager._logger = logger + @staticmethod + def read_api_file( + api_schema: Dict, + api_config: Dict, + model_plugins: Dict, + serializer_plugins: Dict, + ) -> Tuple[bool, Union[IModel, None], Union[ISerializer, None], str]: + """ + A method to read the api configuration and schema and return the model instance and serializer instance + + Args: + api_schema (Dict): OpenAPI schema + api_config (Dict): OpenAPI configuration + model_plugins (Dict): A dictionary of supported model plugins + serializer_plugins (Dict): A dictionary of supported serializer plugins + + Returns: + Tuple[bool, Union[IModel, None], Union[ISerializer, None], str]: + Returns a tuple consisting of bool that indicates if it succeeds, + If it succeeds, it will contain an object of IModel, and an object of ISerializer + and returns an empty string + If it fails to deserialize/identify, it will contain None objects and returns the error message + """ + return_model_instance = None + return_model_serializer_instance = None + log_message( + ModelManager._logger, + logging.INFO, + f"Attempting to read api:" + f"APISchema: {api_schema}" + f"APIConfig: {api_config}", + ) + + # Validate the inputs + if ( + api_schema is None + or not isinstance(api_schema, dict) + or api_config is None + or not isinstance(api_config, dict) + or model_plugins is None + or not isinstance(model_plugins, dict) + or serializer_plugins is None + or not isinstance(serializer_plugins, dict) + ): + error_message = ( + f"There was an error validating the input parameters: {api_schema}, {api_config}," + f"{model_plugins}, {serializer_plugins}" + ) + log_message(ModelManager._logger, logging.ERROR, error_message) + return ( + False, + return_model_instance, + return_model_serializer_instance, + error_message, + ) + else: + log_message( + ModelManager._logger, logging.INFO, "Model validation successful" + ) + @staticmethod def read_model_file( model_file: str, model_plugins: Dict, serializer_plugins: Dict diff --git a/test-engine-core/test_engine_core/plugins/plugins_manager.py b/test-engine-core/test_engine_core/plugins/plugins_manager.py index ca2df3d99..ca46b7baf 100644 --- a/test-engine-core/test_engine_core/plugins/plugins_manager.py +++ b/test-engine-core/test_engine_core/plugins/plugins_manager.py @@ -283,7 +283,26 @@ def _get_model_serializer_instance( # Process differently if it is API, or UPLOAD. if model_mode is ModelModeType.API: - raise RuntimeError("There was an error loading model(api)") + api_schema = arguments.get("api_schema", dict()) + api_config = arguments.get("api_config", dict()) + ( + is_success, + model_instance, + serializer_instance, + error_message, + ) = ModelManager.read_api_file( + api_schema, + api_config, + PluginManager._get_plugins_by_type(PluginType.MODEL), + PluginManager._get_plugins_by_type(PluginType.SERIALIZER), + ) + + if is_success: + return model_instance, serializer_instance, error_message + else: + raise RuntimeError( + f"There was an error loading model(api): ({error_message})" + ) else: filename = arguments.get("filename", "") From 7ce838fbbc827448d92d976ed65be1e9ad230442 Mon Sep 17 00:00:00 2001 From: Lionel Teo <93119265+imda-lionelteo@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:32:00 +0000 Subject: [PATCH 031/176] update format to identify format for data model and pipeline --- .../src/apiconnector/apiconnector.py | 5 +- .../src/delimiterdata/delimiterdata.py | 3 +- .../src/imagedata/imagedata.py | 3 +- .../src/lightgbmmodel/lightgbmmodel.py | 3 +- .../src/pandasdata/pandasdata.py | 3 +- .../src/sklearnmodel/sklearnmodel.py | 3 +- .../src/sklearnpipeline/sklearnpipeline.py | 3 +- .../src/tensorflowmodel/tensorflowmodel.py | 3 +- .../src/xgboostmodel/xgboostmodel.py | 3 +- .../test_engine_core/interfaces/idata.py | 2 +- .../test_engine_core/interfaces/imodel.py | 2 +- .../test_engine_core/interfaces/ipipeline.py | 2 +- .../test_engine_core/plugins/data_manager.py | 15 +++-- .../test_engine_core/plugins/model_manager.py | 65 +++++++++++++------ .../plugins/pipeline_manager.py | 10 +-- .../plugins/plugins_manager.py | 5 +- 16 files changed, 83 insertions(+), 47 deletions(-) diff --git a/test-engine-core-modules/src/apiconnector/apiconnector.py b/test-engine-core-modules/src/apiconnector/apiconnector.py index 9f6621e2a..a6759a80c 100644 --- a/test-engine-core-modules/src/apiconnector/apiconnector.py +++ b/test-engine-core-modules/src/apiconnector/apiconnector.py @@ -65,9 +65,12 @@ def get_model_plugin_type() -> ModelPluginType: """ return Plugin._model_plugin_type - def __init__(self, api_schema: Dict, api_config: Dict) -> None: + def __init__(self, **kwargs) -> None: # Configuration self._is_setup_completed = False + api_schema = kwargs.get("api_schema", None) + api_config = kwargs.get("api_config", None) + if api_schema and api_config: self._api_schema = api_schema self._api_config = api_config diff --git a/test-engine-core-modules/src/delimiterdata/delimiterdata.py b/test-engine-core-modules/src/delimiterdata/delimiterdata.py index 19f7d21ab..f7da4ed44 100644 --- a/test-engine-core-modules/src/delimiterdata/delimiterdata.py +++ b/test-engine-core-modules/src/delimiterdata/delimiterdata.py @@ -56,7 +56,8 @@ def get_data_plugin_type() -> DataPluginType: """ return Plugin._data_plugin_type - def __init__(self, data: DelimiterMetadata) -> None: + def __init__(self, **kwargs) -> None: + data = kwargs.get("data", None) if isinstance(data, DelimiterMetadata) and data: self._data = data diff --git a/test-engine-core-modules/src/imagedata/imagedata.py b/test-engine-core-modules/src/imagedata/imagedata.py index ee2eeaa9f..365bbdb72 100755 --- a/test-engine-core-modules/src/imagedata/imagedata.py +++ b/test-engine-core-modules/src/imagedata/imagedata.py @@ -56,7 +56,8 @@ def get_data_plugin_type() -> DataPluginType: """ return Plugin._data_plugin_type - def __init__(self, data: ImageMetadata) -> None: + def __init__(self, **kwargs) -> None: + data = kwargs.get("data", None) if isinstance(data, ImageMetadata) and data: self._data = data diff --git a/test-engine-core-modules/src/lightgbmmodel/lightgbmmodel.py b/test-engine-core-modules/src/lightgbmmodel/lightgbmmodel.py index 37234c3de..f9f21eee7 100644 --- a/test-engine-core-modules/src/lightgbmmodel/lightgbmmodel.py +++ b/test-engine-core-modules/src/lightgbmmodel/lightgbmmodel.py @@ -56,7 +56,8 @@ def get_model_plugin_type() -> ModelPluginType: """ return Plugin._model_plugin_type - def __init__(self, model: Any) -> None: + def __init__(self, **kwargs) -> None: + model = kwargs.get("model", None) if model: self._model = model diff --git a/test-engine-core-modules/src/pandasdata/pandasdata.py b/test-engine-core-modules/src/pandasdata/pandasdata.py index 8bf328d08..af55d881a 100644 --- a/test-engine-core-modules/src/pandasdata/pandasdata.py +++ b/test-engine-core-modules/src/pandasdata/pandasdata.py @@ -57,7 +57,8 @@ def get_data_plugin_type() -> DataPluginType: """ return Plugin._data_plugin_type - def __init__(self, data: DataFrame = None) -> None: + def __init__(self, **kwargs) -> None: + data = kwargs.get("data", None) if isinstance(data, DataFrame) and not data.empty: self._data = data diff --git a/test-engine-core-modules/src/sklearnmodel/sklearnmodel.py b/test-engine-core-modules/src/sklearnmodel/sklearnmodel.py index 3ae16bd96..35ccb5d08 100644 --- a/test-engine-core-modules/src/sklearnmodel/sklearnmodel.py +++ b/test-engine-core-modules/src/sklearnmodel/sklearnmodel.py @@ -67,7 +67,8 @@ def get_model_plugin_type() -> ModelPluginType: """ return Plugin._model_plugin_type - def __init__(self, model: Any) -> None: + def __init__(self, **kwargs) -> None: + model = kwargs.get("model", None) if model: self._model = model diff --git a/test-engine-core-modules/src/sklearnpipeline/sklearnpipeline.py b/test-engine-core-modules/src/sklearnpipeline/sklearnpipeline.py index 23b4bafdd..7b06a3265 100644 --- a/test-engine-core-modules/src/sklearnpipeline/sklearnpipeline.py +++ b/test-engine-core-modules/src/sklearnpipeline/sklearnpipeline.py @@ -56,7 +56,8 @@ def get_pipeline_plugin_type() -> PipelinePluginType: """ return Plugin._pipeline_plugin_type - def __init__(self, pipeline: Any) -> None: + def __init__(self, **kwargs) -> None: + pipeline = kwargs.get("pipeline", None) if pipeline: self._pipeline = pipeline diff --git a/test-engine-core-modules/src/tensorflowmodel/tensorflowmodel.py b/test-engine-core-modules/src/tensorflowmodel/tensorflowmodel.py index 97aac0c24..e0a4613f7 100644 --- a/test-engine-core-modules/src/tensorflowmodel/tensorflowmodel.py +++ b/test-engine-core-modules/src/tensorflowmodel/tensorflowmodel.py @@ -56,7 +56,8 @@ def get_model_plugin_type() -> ModelPluginType: """ return Plugin._model_plugin_type - def __init__(self, model: Any) -> None: + def __init__(self, **kwargs) -> None: + model = kwargs.get("model", None) if model: self._model = model diff --git a/test-engine-core-modules/src/xgboostmodel/xgboostmodel.py b/test-engine-core-modules/src/xgboostmodel/xgboostmodel.py index afef66b04..1957a0fcd 100644 --- a/test-engine-core-modules/src/xgboostmodel/xgboostmodel.py +++ b/test-engine-core-modules/src/xgboostmodel/xgboostmodel.py @@ -61,7 +61,8 @@ def get_model_plugin_type() -> ModelPluginType: """ return Plugin._model_plugin_type - def __init__(self, model: Any) -> None: + def __init__(self, **kwargs) -> None: + model = kwargs.get("model", None) if model: self._model = model diff --git a/test-engine-core/test_engine_core/interfaces/idata.py b/test-engine-core/test_engine_core/interfaces/idata.py index ff8a726a1..711055a1d 100644 --- a/test-engine-core/test_engine_core/interfaces/idata.py +++ b/test-engine-core/test_engine_core/interfaces/idata.py @@ -16,7 +16,7 @@ def get_data_plugin_type() -> DataPluginType: pass @abstractmethod - def __init__(self, data: Any) -> None: + def __init__(self, **kwargs) -> None: pass @abstractmethod diff --git a/test-engine-core/test_engine_core/interfaces/imodel.py b/test-engine-core/test_engine_core/interfaces/imodel.py index 97f196693..3a38f715a 100755 --- a/test-engine-core/test_engine_core/interfaces/imodel.py +++ b/test-engine-core/test_engine_core/interfaces/imodel.py @@ -16,7 +16,7 @@ def get_model_plugin_type() -> ModelPluginType: pass @abstractmethod - def __init__(self, model: Any) -> None: + def __init__(self, **kwargs) -> None: pass @abstractmethod diff --git a/test-engine-core/test_engine_core/interfaces/ipipeline.py b/test-engine-core/test_engine_core/interfaces/ipipeline.py index 9b0120614..ee90f8858 100644 --- a/test-engine-core/test_engine_core/interfaces/ipipeline.py +++ b/test-engine-core/test_engine_core/interfaces/ipipeline.py @@ -16,7 +16,7 @@ def get_pipeline_plugin_type() -> PipelinePluginType: pass @abstractmethod - def __init__(self, pipeline: Any) -> None: + def __init__(self, **kwargs) -> None: pass @abstractmethod diff --git a/test-engine-core/test_engine_core/plugins/data_manager.py b/test-engine-core/test_engine_core/plugins/data_manager.py index 999112368..ffa15d49d 100644 --- a/test-engine-core/test_engine_core/plugins/data_manager.py +++ b/test-engine-core/test_engine_core/plugins/data_manager.py @@ -225,7 +225,9 @@ def _consolidate_image_paths_to_df( ( is_success, data_instance, - ) = DataManager._try_to_identify_data_format(df_data, data_plugins) + ) = DataManager._try_to_identify_data_format( + data_plugins, {"data": df_data} + ) return is_success, data_instance, "" else: @@ -262,7 +264,9 @@ def _convert_to_pandas( ( is_success, data_instance, - ) = DataManager._try_to_identify_data_format(df_data, data_plugins) + ) = DataManager._try_to_identify_data_format( + data_plugins, {"data": df_data} + ) return is_success, data_instance, "" else: @@ -300,7 +304,7 @@ def _read_data_path( # Attempt to identify the data format with the supported list. is_success, data_instance = DataManager._try_to_identify_data_format( - data, data_plugins + data_plugins, {"data", data} ) if not is_success: error_message = f"There was an error identifying dataset: {type(data)}" @@ -350,13 +354,12 @@ def _try_to_deserialize_data( @staticmethod def _try_to_identify_data_format( - data: Any, data_plugins: Dict + data_plugins: Dict, **kwargs ) -> Tuple[bool, IData]: """ A helper method to identify the data and return the respective data format instance Args: - data (Any): The de-serialized data data_plugins (Dict): A dictionary of supported data plugins Returns: @@ -372,7 +375,7 @@ def _try_to_identify_data_format( # Check that this data is one of the supported data formats try: for _, data_plugin in data_plugins.items(): - temp_data_instance = data_plugin.Plugin(data) + temp_data_instance = data_plugin.Plugin(**kwargs) if temp_data_instance.is_supported(): data_instance = temp_data_instance is_success = True diff --git a/test-engine-core/test_engine_core/plugins/model_manager.py b/test-engine-core/test_engine_core/plugins/model_manager.py index 4b9d7f112..0976c384c 100644 --- a/test-engine-core/test_engine_core/plugins/model_manager.py +++ b/test-engine-core/test_engine_core/plugins/model_manager.py @@ -27,25 +27,23 @@ def set_logger(logger: logging.Logger) -> None: ModelManager._logger = logger @staticmethod - def read_api_file( + def read_api( api_schema: Dict, api_config: Dict, model_plugins: Dict, - serializer_plugins: Dict, - ) -> Tuple[bool, Union[IModel, None], Union[ISerializer, None], str]: + ) -> Tuple[bool, Union[IModel, None], None, str]: """ - A method to read the api configuration and schema and return the model instance and serializer instance + A method to read the api configuration and schema and return the model instance and None (serializer instance) Args: api_schema (Dict): OpenAPI schema api_config (Dict): OpenAPI configuration model_plugins (Dict): A dictionary of supported model plugins - serializer_plugins (Dict): A dictionary of supported serializer plugins Returns: - Tuple[bool, Union[IModel, None], Union[ISerializer, None], str]: + Tuple[bool, Union[IModel, None], None, str]: Returns a tuple consisting of bool that indicates if it succeeds, - If it succeeds, it will contain an object of IModel, and an object of ISerializer + If it succeeds, it will contain an object of IModel, and None (object of ISerializer) and returns an empty string If it fails to deserialize/identify, it will contain None objects and returns the error message """ @@ -54,9 +52,7 @@ def read_api_file( log_message( ModelManager._logger, logging.INFO, - f"Attempting to read api:" - f"APISchema: {api_schema}" - f"APIConfig: {api_config}", + f"Attempting to read api: api_schema: {api_schema}, api_config: {api_config}", ) # Validate the inputs @@ -67,13 +63,9 @@ def read_api_file( or not isinstance(api_config, dict) or model_plugins is None or not isinstance(model_plugins, dict) - or serializer_plugins is None - or not isinstance(serializer_plugins, dict) ): - error_message = ( - f"There was an error validating the input parameters: {api_schema}, {api_config}," - f"{model_plugins}, {serializer_plugins}" - ) + error_message = f"There was an error validating the input parameters: {api_schema}, {api_config}, \ + {model_plugins}" log_message(ModelManager._logger, logging.ERROR, error_message) return ( False, @@ -86,6 +78,39 @@ def read_api_file( ModelManager._logger, logging.INFO, "Model validation successful" ) + # Attempt to identify the model format with the supported list. + # If model is not in the supported list, it will return False + log_message( + ModelManager._logger, + logging.INFO, + "Attempting to identify model format with api schema", + ) + is_success, return_model_instance = ModelManager._try_to_identify_model_format( + model_plugins, {"api_schema": api_schema, "api_config": api_config} + ) + if is_success: + error_message = "" + log_message( + ModelManager._logger, + logging.INFO, + f"Supported model format for api schema: " + f"{return_model_instance.get_model_plugin_type()}[{return_model_instance.get_model_algorithm()}]", + ) + else: + # Failed to get model format + return_model_instance = None + error_message = ( + "There was an error getting model format with api schema (unsupported)" + ) + log_message(ModelManager._logger, logging.ERROR, error_message) + + return ( + is_success, + return_model_instance, + return_model_serializer_instance, + error_message, + ) + @staticmethod def read_model_file( model_file: str, model_plugins: Dict, serializer_plugins: Dict @@ -170,7 +195,7 @@ def read_model_file( f"Attempting to identify model format: {type(model)}", ) is_success, return_model_instance = ModelManager._try_to_identify_model_format( - model, model_plugins + model_plugins, {"model": model} ) if is_success: error_message = "" @@ -236,14 +261,12 @@ def _try_to_deserialize_model( @staticmethod def _try_to_identify_model_format( - model: Any, - model_plugins: Dict, + model_plugins: Dict, **kwargs ) -> Tuple[bool, IModel]: """ A helper method to read the model and return the respective model format instance Args: - model (Any): The de-serialized model model_plugins (Dict): The dictionary of detected model plugins Returns: @@ -259,7 +282,7 @@ def _try_to_identify_model_format( # Check that this model is one of the supported model formats try: for _, model_plugin in model_plugins.items(): - model_instance = model_plugin.Plugin(model) + model_instance = model_plugin.Plugin(**kwargs) if model_instance.is_supported(): is_success = True break diff --git a/test-engine-core/test_engine_core/plugins/pipeline_manager.py b/test-engine-core/test_engine_core/plugins/pipeline_manager.py index 2e9504608..93f8bff01 100644 --- a/test-engine-core/test_engine_core/plugins/pipeline_manager.py +++ b/test-engine-core/test_engine_core/plugins/pipeline_manager.py @@ -143,7 +143,9 @@ def read_pipeline_path( ( is_success, return_pipeline_instance, - ) = PipelineManager._try_to_identify_pipeline_format(pipeline, pipeline_plugins) + ) = PipelineManager._try_to_identify_pipeline_format( + pipeline_plugins, {"pipeline": pipeline} + ) if is_success: error_message = "" log_message( @@ -208,14 +210,12 @@ def _try_to_deserialize_pipeline( @staticmethod def _try_to_identify_pipeline_format( - pipeline: Any, - pipeline_plugins: Dict, + pipeline_plugins: Dict, **kwargs ) -> Tuple[bool, IPipeline]: """ A helper method to read the pipeline and return the respective pipeline format instance Args: - pipeline (Any): The de-serialized pipeline pipeline_plugins (Dict): The dictionary of detected pipeline plugins Returns: @@ -231,7 +231,7 @@ def _try_to_identify_pipeline_format( # Check that this pipeline is one of the supported pipeline formats try: for _, pipeline_plugin in pipeline_plugins.items(): - pipeline_instance = pipeline_plugin.Plugin(pipeline) + pipeline_instance = pipeline_plugin.Plugin(**kwargs) if pipeline_instance.is_supported(): is_success = True break diff --git a/test-engine-core/test_engine_core/plugins/plugins_manager.py b/test-engine-core/test_engine_core/plugins/plugins_manager.py index ca46b7baf..2883392a7 100644 --- a/test-engine-core/test_engine_core/plugins/plugins_manager.py +++ b/test-engine-core/test_engine_core/plugins/plugins_manager.py @@ -290,18 +290,17 @@ def _get_model_serializer_instance( model_instance, serializer_instance, error_message, - ) = ModelManager.read_api_file( + ) = ModelManager.read_api( api_schema, api_config, PluginManager._get_plugins_by_type(PluginType.MODEL), - PluginManager._get_plugins_by_type(PluginType.SERIALIZER), ) if is_success: return model_instance, serializer_instance, error_message else: raise RuntimeError( - f"There was an error loading model(api): ({error_message})" + f"There was an error loading model(api): {api_schema} | {api_config} ({error_message})" ) else: From d21abf0318404f7c2ad8fe7975d05993c6ca3b76 Mon Sep 17 00:00:00 2001 From: Lionel Teo <93119265+imda-lionelteo@users.noreply.github.com> Date: Fri, 14 Jul 2023 07:43:02 +0000 Subject: [PATCH 032/176] provide fix for kwargs inputs, clean up apiconnector and plugin test --- .../src/apiconnector/apiconnector.py | 153 ++++-------------- .../apiconnector/module_tests/plugin_test.py | 86 +++++----- .../test_engine_core/plugins/data_manager.py | 6 +- .../test_engine_core/plugins/model_manager.py | 4 +- .../plugins/pipeline_manager.py | 2 +- 5 files changed, 80 insertions(+), 171 deletions(-) diff --git a/test-engine-core-modules/src/apiconnector/apiconnector.py b/test-engine-core-modules/src/apiconnector/apiconnector.py index a6759a80c..1b802bc3e 100644 --- a/test-engine-core-modules/src/apiconnector/apiconnector.py +++ b/test-engine-core-modules/src/apiconnector/apiconnector.py @@ -1,19 +1,14 @@ from __future__ import annotations -import pathlib from typing import Any, Dict, List, Tuple import httpx -from aiopenapi3 import FileSystemLoader, OpenAPI from openapi_schema_validator import OAS30Validator, validate from test_engine_core.interfaces.imodel import IModel from test_engine_core.plugins.enums.model_plugin_type import ModelPluginType from test_engine_core.plugins.enums.plugin_type import PluginType from test_engine_core.plugins.metadata.plugin_metadata import PluginMetadata -# from requests import Session, session -# from requests.adapters import HTTPAdapter, Retry - # NOTE: Do not change the class name, else the plugin cannot be read by the system class Plugin(IModel): @@ -65,6 +60,18 @@ def get_model_plugin_type() -> ModelPluginType: """ return Plugin._model_plugin_type + @staticmethod + def session_factory(*args, **kwargs) -> httpx.AsyncClient: + """ + A factory that generates async client + + Returns: + httpx.AsyncClient: Returns an httpx AsyncClient with additional settings + """ + kwargs["verify"] = False + kwargs["timeout"] = 5.0 # seconds + return httpx.AsyncClient(*args, **kwargs) + def __init__(self, **kwargs) -> None: # Configuration self._is_setup_completed = False @@ -78,41 +85,13 @@ def __init__(self, **kwargs) -> None: self._api_schema: Dict = dict() self._api_config: Dict = dict() - # # API variables - # self._openapi3_inst = None - # self._default_api_retries: int = 3 - # self._session: Union[Session, None] = None - # self._additional_headers: Dict = dict() - # self._auth_info: Dict = dict() - # # (0s, 2s, 4s) - # # Formula: {backoff factor} * (2 ** ({number of total retries} - 1)) - # self._default_api_backoff: float = 1.0 - # self._default_api_timeout: float = 5.0 # seconds - # self._default_api_status_code: list = [429, 500, 502, 503, 504] - # self._default_api_allowed_methods: list = ["GET", "POST"] - - @staticmethod - def session_factory(*args, **kwargs) -> httpx.AsyncClient: - """ - A factory that generates async client - - Returns: - httpx.AsyncClient: Returns an httpx AsyncClient with additional settings - """ - kwargs["verify"] = False - kwargs["timeout"] = 5.0 # seconds - return httpx.AsyncClient(*args, **kwargs) - def cleanup(self) -> None: """ A method to clean-up objects """ - if self._session is not None: - self._session.close() - else: - pass # pragma: no cover + pass - async def setup(self) -> Tuple[bool, str]: + def setup(self) -> Tuple[bool, str]: """ A method to perform setup @@ -121,62 +100,24 @@ async def setup(self) -> Tuple[bool, str]: error message if failed. """ try: - token = "" - api = OpenAPI.load_file( - url="", - path=pathlib.Path("test_api_config.json"), - session_factory=Plugin.session_factory, - loader=FileSystemLoader( - pathlib.Path( - "/workspaces/aiverify-backend-dev/aiverify/test-engine-core-modules/src/apiconnector" - ) - ), - ) - api.authenticate(myAuth=f"token {token}") - predict_request = api.createRequest(("/predict/tc007", "post")) - body = predict_request.data.get_type().parse_obj( - {"age": 14, "gender": 13, "race": 13, "count": 13, "charge": 13} - ) - headers, data, result = await predict_request.request( - parameters={"foo": "bar"}, data=body - ) - print(result.text, result.status_code, result.content) - # # Search for the first api and http method. - # # Set the prediction operationId - # path_to_be_updated = self._api_schema["paths"] - # if len(path_to_be_updated) > 0: - # first_api = list(path_to_be_updated.items())[0] - # first_api_value = first_api[1] - # if len(first_api_value) > 0: - # first_api_http = list(first_api_value.items())[0] - # first_api_http_value = first_api_http[1] - # first_api_http_value.update({"operationId": "predict_api"}) - - # # Parse the openapi schema - # self._openapi3_inst = OpenAPI(self._api_schema, validate=True) - # self._setup_authentication() - - # # Prepare headers information for sending query - # # Convert headers object into key-attribute mapping in dict - # if "headers" in self._api_config.keys(): - # self._additional_headers = self._api_config["headers"] - # else: - # self._additional_headers = dict() - - # # Setup session retry strategy - # # It will perform 3 times retries and have default backoff time for status_forcelist error code - # # It will perform for methods in whitelist - # retry_strategy = Retry( - # total=self._default_api_retries, - # backoff_factor=self._default_api_backoff, - # status_forcelist=self._default_api_status_code, - # allowed_methods=self._default_api_allowed_methods, - # ) - # adapter = HTTPAdapter(max_retries=retry_strategy) - # self._session = session() - # self._session.verify = False - # self._session.mount("https://", adapter) - # self._session.mount("http://", adapter) + # Perform OpenAPI3 schema validation + # An exception will be thrown if validation has errors + validate(self._api_config, self._api_schema, cls=OAS30Validator) + + # Search for the first api and http method. + # Set the prediction operationId + path_to_be_updated = self._api_schema["paths"] + if len(path_to_be_updated) > 0: + first_api = list(path_to_be_updated.items())[0] + first_api_value = first_api[1] + if len(first_api_value) > 0: + first_api_http = list(first_api_value.items())[0] + first_api_http_value = first_api_http[1] + first_api_http_value.update({"operationId": "predict_api"}) + + # TODO: Create the api instance + + # TODO: Setup API Authentication # Setup completed self._is_setup_completed = True @@ -233,10 +174,6 @@ def predict(self, data: Any, *args) -> Any: Any: predicted result """ pass - # try: - # return self._model.predict(data) - # except Exception: - # raise def predict_proba(self, data: Any, *args) -> Any: """ @@ -249,10 +186,6 @@ def predict_proba(self, data: Any, *args) -> Any: Any: predicted result """ pass - # try: - # return self._model.predict_proba(data) - # except Exception: - # raise def score(self, data: Any, y_true: Any) -> Any: """ @@ -266,25 +199,3 @@ def score(self, data: Any, y_true: Any) -> Any: Any: score result """ raise RuntimeError("ApiConnector does not support score method") - - # def _identify_model_algorithm(self, model: Any) -> Tuple[bool, str]: - # """ - # A helper method to identify the model algorithm whether it is being supported - - # Args: - # model (Any): the model to be checked against the supported model list - - # Returns: - # Tuple[bool, str]: true if model is supported, str will store the support - # algo name - # """ - # model_algorithm = "" - # is_success = False - - # module_type_name = f"{type(model).__module__}.{type(model).__name__}" - # for supported_algo in self._supported_algorithms: - # if supported_algo == module_type_name: - # model_algorithm = supported_algo - # is_success = True - - # return is_success, model_algorithm diff --git a/test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py b/test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py index 0a9622902..e2ae490f3 100644 --- a/test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py +++ b/test-engine-core-modules/src/apiconnector/module_tests/plugin_test.py @@ -47,50 +47,48 @@ def run(self) -> None: # Load all the core plugins and the model plugin PluginManager.discover(str(self._base_path)) - # # Get the model instance - # ( - # self._model_instance, - # self._model_serializer_instance, - # error_message, - # ) = PluginManager.get_instance( - # PluginType.MODEL, - # **{ - # "mode": ModelModeType.API, - # "api_schema": self._api_schema, - # "api_config": self._api_config, - # } - # ) - - print("Helloworld") - - # # Perform model instance setup - # is_success, error_messages = self._model_instance.setup() - # if not is_success: - # raise RuntimeError( - # f"Failed to perform model instance setup: {error_messages}" - # ) - - # # Run different tests on the model instance - # test_methods = [ - # self._validate_metadata, - # self._validate_plugin_type, - # self._validate_model_supported, - # ] - - # for method in test_methods: - # tmp_count, tmp_error_msg = method() - # error_count += tmp_count - # error_message += tmp_error_msg - - # # Perform cleanup - # self._model_instance.cleanup() - - # if error_count > 0: - # print(f"Errors found while running tests. {error_message}") - # sys.exit(-1) - # else: - # print("No errors found. Test completed successfully.") - # sys.exit(0) + # Get the model instance + ( + self._model_instance, + self._model_serializer_instance, + error_message, + ) = PluginManager.get_instance( + PluginType.MODEL, + **{ + "mode": ModelModeType.API, + "api_schema": self._api_schema, + "api_config": self._api_config, + }, + ) + + # Perform model instance setup + is_success, error_messages = self._model_instance.setup() + if not is_success: + raise RuntimeError( + f"Failed to perform model instance setup: {error_messages}" + ) + + # Run different tests on the model instance + test_methods = [ + self._validate_metadata, + self._validate_plugin_type, + self._validate_model_supported, + ] + + for method in test_methods: + tmp_count, tmp_error_msg = method() + error_count += tmp_count + error_message += tmp_error_msg + + # Perform cleanup + self._model_instance.cleanup() + + if error_count > 0: + print(f"Errors found while running tests. {error_message}") + sys.exit(-1) + else: + print("No errors found. Test completed successfully.") + sys.exit(0) except Exception as error: # Print and exit with error diff --git a/test-engine-core/test_engine_core/plugins/data_manager.py b/test-engine-core/test_engine_core/plugins/data_manager.py index ffa15d49d..69a3a7172 100644 --- a/test-engine-core/test_engine_core/plugins/data_manager.py +++ b/test-engine-core/test_engine_core/plugins/data_manager.py @@ -226,7 +226,7 @@ def _consolidate_image_paths_to_df( is_success, data_instance, ) = DataManager._try_to_identify_data_format( - data_plugins, {"data": df_data} + data_plugins, **{"data": df_data} ) return is_success, data_instance, "" @@ -265,7 +265,7 @@ def _convert_to_pandas( is_success, data_instance, ) = DataManager._try_to_identify_data_format( - data_plugins, {"data": df_data} + data_plugins, **{"data": df_data} ) return is_success, data_instance, "" @@ -304,7 +304,7 @@ def _read_data_path( # Attempt to identify the data format with the supported list. is_success, data_instance = DataManager._try_to_identify_data_format( - data_plugins, {"data", data} + data_plugins, **{"data": data} ) if not is_success: error_message = f"There was an error identifying dataset: {type(data)}" diff --git a/test-engine-core/test_engine_core/plugins/model_manager.py b/test-engine-core/test_engine_core/plugins/model_manager.py index 0976c384c..b7705246b 100644 --- a/test-engine-core/test_engine_core/plugins/model_manager.py +++ b/test-engine-core/test_engine_core/plugins/model_manager.py @@ -86,7 +86,7 @@ def read_api( "Attempting to identify model format with api schema", ) is_success, return_model_instance = ModelManager._try_to_identify_model_format( - model_plugins, {"api_schema": api_schema, "api_config": api_config} + model_plugins, **{"api_schema": api_schema, "api_config": api_config} ) if is_success: error_message = "" @@ -195,7 +195,7 @@ def read_model_file( f"Attempting to identify model format: {type(model)}", ) is_success, return_model_instance = ModelManager._try_to_identify_model_format( - model_plugins, {"model": model} + model_plugins, **{"model": model} ) if is_success: error_message = "" diff --git a/test-engine-core/test_engine_core/plugins/pipeline_manager.py b/test-engine-core/test_engine_core/plugins/pipeline_manager.py index 93f8bff01..bdc27f7d6 100644 --- a/test-engine-core/test_engine_core/plugins/pipeline_manager.py +++ b/test-engine-core/test_engine_core/plugins/pipeline_manager.py @@ -144,7 +144,7 @@ def read_pipeline_path( is_success, return_pipeline_instance, ) = PipelineManager._try_to_identify_pipeline_format( - pipeline_plugins, {"pipeline": pipeline} + pipeline_plugins, **{"pipeline": pipeline} ) if is_success: error_message = "" From 42c080e0c8c43730d23138a5afeb0ecc960d9a5b Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 14 Jul 2023 18:02:47 +0800 Subject: [PATCH 033/176] check for filePath instead of modelFile --- ai-verify-apigw/lib/testEngineQueue.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-verify-apigw/lib/testEngineQueue.mjs b/ai-verify-apigw/lib/testEngineQueue.mjs index b54ba8a25..57a5cd966 100644 --- a/ai-verify-apigw/lib/testEngineQueue.mjs +++ b/ai-verify-apigw/lib/testEngineQueue.mjs @@ -89,8 +89,8 @@ export const queueTests = async (report, modelAndDatasets) => { throw new Error("Missing apiConfig information"); } } else { - if (!modelAndDatasets.model.modelFile) { - throw new Error("Missing modelFile information"); + if (!modelAndDatasets.model.filePath) { + throw new Error("Missing filePath information"); } } From 0c3692095d1d4126016e37ca69dabf59d26bfe6e Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 14 Jul 2023 18:03:16 +0800 Subject: [PATCH 034/176] add jest/globals --- ai-verify-apigw/.eslintrc.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ai-verify-apigw/.eslintrc.js b/ai-verify-apigw/.eslintrc.js index 606c1be91..42e0f6306 100644 --- a/ai-verify-apigw/.eslintrc.js +++ b/ai-verify-apigw/.eslintrc.js @@ -3,7 +3,8 @@ module.exports = { "browser": true, "node": true, "commonjs": true, - "es2021": true + "es2021": true, + "jest/globals": true }, "extends": [ "eslint:recommended", From 49d76572fa9e77fc14d55e3840e387508ba3a3df Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 14 Jul 2023 18:03:48 +0800 Subject: [PATCH 035/176] fix test unit errors --- .../__tests__/graphql/model.test.mjs | 2 - .../__tests__/graphql/project.test.mjs | 8 +- .../__tests__/lib/testEngineQueue.test.mjs | 4 +- .../__tests__/routes/report.test.mjs | 2 +- .../__tests__/routes/upload.test.mjs | 2 +- ai-verify-apigw/testutil/mockData.mjs | 121 +++++++++++------- 6 files changed, 81 insertions(+), 58 deletions(-) diff --git a/ai-verify-apigw/__tests__/graphql/model.test.mjs b/ai-verify-apigw/__tests__/graphql/model.test.mjs index 9a1eaad4f..44a840453 100644 --- a/ai-verify-apigw/__tests__/graphql/model.test.mjs +++ b/ai-verify-apigw/__tests__/graphql/model.test.mjs @@ -164,7 +164,6 @@ describe("Test Model GraphQL queries and mutations", () => { description: 'Mock Description1', modelType: 'Regression', name: 'New File Name1.png', - status: 'Cancelled', } } }) @@ -177,7 +176,6 @@ describe("Test Model GraphQL queries and mutations", () => { const doc = await ModelFileModel.findOne({ _id: mongoose.Types.ObjectId(id) }); expect(doc.name).toEqual('New File Name1.png'); expect(doc.modelType).toEqual('Regression'); - expect(doc.status).toEqual('Cancelled'); expect(doc.description).toEqual('Mock Description1'); }) diff --git a/ai-verify-apigw/__tests__/graphql/project.test.mjs b/ai-verify-apigw/__tests__/graphql/project.test.mjs index 1ef035963..6c3e30ac5 100644 --- a/ai-verify-apigw/__tests__/graphql/project.test.mjs +++ b/ai-verify-apigw/__tests__/graphql/project.test.mjs @@ -1,7 +1,7 @@ import {jest} from '@jest/globals' import mongoose from 'mongoose'; import casual from '#testutil/mockData.mjs'; -import { mockModel, mockTestDataset } from '../../testutil/mockData.mjs'; +// import { mockModel, mockTestDataset } from '../../testutil/mockData.mjs'; describe("Test Project GraphQL queries", () => { @@ -37,15 +37,15 @@ describe("Test Project GraphQL queries", () => { ModelFileModel = models.ModelFileModel; DatasetFileModel = models.DatasetModel; // create some initial data - const model = new ModelFileModel(mockModel); - const dataset = new DatasetFileModel(mockTestDataset); + const model = new ModelFileModel(casual.modelFile); + const dataset = new DatasetFileModel(casual.testDataset); const savedModel = await model.save(); const savedDataset = await dataset.save(); const docs = casual.multipleProjects(2); for (const doc of docs) { doc.__t = 'ProjectModel'; doc.modelAndDatasets = { - groundTruthColumn: 'two_year_recid', + groundTruthColumn: 'donation', model: savedModel._id, testDataset: savedDataset._id, groundTruthDataset: savedDataset._id, diff --git a/ai-verify-apigw/__tests__/lib/testEngineQueue.test.mjs b/ai-verify-apigw/__tests__/lib/testEngineQueue.test.mjs index 659767649..5bfdcf702 100644 --- a/ai-verify-apigw/__tests__/lib/testEngineQueue.test.mjs +++ b/ai-verify-apigw/__tests__/lib/testEngineQueue.test.mjs @@ -53,8 +53,8 @@ describe("Test module testEngineQueue.mjs", () => { it("should queue tests", async() => { redis.hSet.mockResolvedValue(); redis.xAdd.mockResolvedValue(); - const modelAndDataset = casual.modelAndDataset; - await testEngineQueue.queueTests(reportData, modelAndDataset) + const modelAndDatasets = casual.modelAndDatasets('File'); + await testEngineQueue.queueTests(reportData, modelAndDatasets) expect(redis.hSet).toHaveBeenCalled(); expect(redis.xAdd).toHaveBeenCalled(); const xAddLastCall = redis.xAdd.mock.lastCall; diff --git a/ai-verify-apigw/__tests__/routes/report.test.mjs b/ai-verify-apigw/__tests__/routes/report.test.mjs index b6ae2f68a..2d1519ecc 100644 --- a/ai-verify-apigw/__tests__/routes/report.test.mjs +++ b/ai-verify-apigw/__tests__/routes/report.test.mjs @@ -40,7 +40,7 @@ describe("Test /report route", () => { const router = await import("#routes/report.mjs"); const app = setupServerWithRouter("/report", router.default); request = supertest(app); - server = app.listen(4010); + server = app.listen(4011); }) afterAll(async() => { diff --git a/ai-verify-apigw/__tests__/routes/upload.test.mjs b/ai-verify-apigw/__tests__/routes/upload.test.mjs index 11aa1223a..9b177d2d8 100644 --- a/ai-verify-apigw/__tests__/routes/upload.test.mjs +++ b/ai-verify-apigw/__tests__/routes/upload.test.mjs @@ -74,7 +74,7 @@ describe("Test /upload route", () => { const router = await import("#routes/upload.mjs"); const app = setupServerWithRouter("/upload", router.default); request = supertest(app); - server = app.listen(4010); + server = app.listen(4012); app.use(multer({}).array()); diff --git a/ai-verify-apigw/testutil/mockData.mjs b/ai-verify-apigw/testutil/mockData.mjs index 6cd41a411..2d4472b24 100644 --- a/ai-verify-apigw/testutil/mockData.mjs +++ b/ai-verify-apigw/testutil/mockData.mjs @@ -217,30 +217,36 @@ casual.define('report', function(project, status) { return report; }) -const mockModel = { - filename: 'pickle_scikit_bc_compas.sav', - name: 'pickle_scikit_bc_compas.sav', - filePath: '/home/test/uploads/model/pickle_scikit_bc_compas.sav', - ctime: new Date('2023-06-05T07:17:25.132Z'), - description: '', - status: 'Valid', - size: '502.71 KB', - modelType: 'Classification', - serializer: 'pickle', - modelFormat: 'sklearn', - errorMessages: '', - type: 'File', - createdAt: new Date('2023-06-05T07:17:25.140Z'), - updatedAt: new Date('2023-06-05T07:17:26.151Z') -} +casual.define('modelFile', function() { + const d = casual.moment.toDate(); + return { + filename: 'pickle_scikit_bc_compas.sav', + name: 'pickle_scikit_bc_compas.sav', + filePath: `/home/test/uploads/model/${casual.word}.sav`, + ctime: d, + description: casual.description, + status: 'Valid', + size: '502.71 KB', + modelType: casual.random_element(['Classification', 'Regression']), + serializer: 'pickle', + modelFormat: 'sklearn', + errorMessages: '', + type: 'File', + createdAt: d, + updatedAt: d + } +}) -const mockTestDataset = { - filename: 'pickle_pandas_tabular_compas_testing.sav', - name: 'pickle_pandas_tabular_compas_testing.sav', +casual.define('testDataset', function() { + const d = casual.moment.toDate(); + const filename = `${casual.word}.sav`; + return { + filename, + name: filename, type: 'File', - filePath: '/home/test/uploads/data/pickle_pandas_tabular_compas_testing.sav', - ctime: '2023-06-05T07:17:06.360Z', - description: '', + filePath: `/home/test/uploads/data/${filename}`, + ctime: d, + description: casual.description, status: 'Valid', size: '68.33 KB', serializer: 'pickle', @@ -248,48 +254,67 @@ const mockTestDataset = { errorMessages: '', dataColumns: [ { - name: 'age_cat_cat', + name: 'age', datatype: 'int64', - label: 'age_cat_cat', - _id: mongoose.Types.ObjectId('647d8bf3ef104c4da904734a') + label: 'age', }, { - name: 'sex_code', + name: 'gender', datatype: 'int64', - label: 'sex_code', - _id: mongoose.Types.ObjectId('647d8bf3ef104c4da904734b') + label: 'gender', }, { - name: 'race_code', + name: 'race', datatype: 'int64', - label: 'race_code', - _id: mongoose.Types.ObjectId('647d8bf3ef104c4da904734c') + label: 'race', }, { - name: 'priors_count', + name: 'income', datatype: 'int64', - label: 'priors_count', - _id: mongoose.Types.ObjectId('647d8bf3ef104c4da904734d') + label: 'income', }, { - name: 'c_charge_degree_cat', + name: 'employment', datatype: 'int64', - label: 'c_charge_degree_cat', - _id: mongoose.Types.ObjectId('647d8bf3ef104c4da904734e') + label: 'employment', }, { - name: 'two_year_recid', + name: 'employment_length', datatype: 'int64', - label: 'two_year_recid', - _id: mongoose.Types.ObjectId('647d8bf3ef104c4da904734f') - } + label: 'employment_length', + }, + { + name: 'total_donated', + datatype: 'int64', + label: 'total_donated', + }, + { + name: 'num_donation', + datatype: 'int64', + label: 'num_donation', + }, + { + name: 'donation', + datatype: 'int64', + label: 'donation', + }, ], - createdAt: new Date('2023-06-05T07:17:06.368Z'), - updatedAt: new Date('2023-06-05T07:17:07.385Z'), - __v: 0, + createdAt: d, + updatedAt: d, numCols: 6, numRows: 1235 -}; + } +}) + +casual.define('modelAndDatasets', function(modelType) { + let testDataset = casual.testDataset; + return { + groundTruthColumn: 'donation', + model: casual.modelFile, + testDataset, + groundTruthDataset: testDataset, + } +}) casual.define('multipleDatasets', function(count) { if (typeof count !== 'number') { @@ -297,7 +322,7 @@ casual.define('multipleDatasets', function(count) { } let ar = []; for (let i=0; i Date: Mon, 17 Jul 2023 12:07:20 +0800 Subject: [PATCH 036/176] remove mock-fs --- ai-verify-apigw/package-lock.json | 10 ---------- ai-verify-apigw/package.json | 1 - 2 files changed, 11 deletions(-) diff --git a/ai-verify-apigw/package-lock.json b/ai-verify-apigw/package-lock.json index 3572ff60b..03e874cf1 100644 --- a/ai-verify-apigw/package-lock.json +++ b/ai-verify-apigw/package-lock.json @@ -45,7 +45,6 @@ "jest": "^29.5.0", "jest-html-reporter": "^3.7.1", "jest-json-reporter": "^1.2.2", - "mock-fs": "^5.2.0", "mongodb-memory-server": "^8.12.1", "supertest": "^6.3.3", "ts-jest": "^29.1.0" @@ -9937,15 +9936,6 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, - "node_modules/mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", - "dev": true, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", diff --git a/ai-verify-apigw/package.json b/ai-verify-apigw/package.json index de9344cb7..293e93be8 100644 --- a/ai-verify-apigw/package.json +++ b/ai-verify-apigw/package.json @@ -53,7 +53,6 @@ "jest": "^29.5.0", "jest-html-reporter": "^3.7.1", "jest-json-reporter": "^1.2.2", - "mock-fs": "^5.2.0", "mongodb-memory-server": "^8.12.1", "supertest": "^6.3.3", "ts-jest": "^29.1.0" From 1b382c90d91606bff3122c20494d01157bfba002 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Mon, 17 Jul 2023 12:08:15 +0800 Subject: [PATCH 037/176] remove mock-fs and fix import issues --- .../__tests__/routes/upload.test.mjs | 285 +++++++++++------- 1 file changed, 171 insertions(+), 114 deletions(-) diff --git a/ai-verify-apigw/__tests__/routes/upload.test.mjs b/ai-verify-apigw/__tests__/routes/upload.test.mjs index 9b177d2d8..1c97fcea6 100644 --- a/ai-verify-apigw/__tests__/routes/upload.test.mjs +++ b/ai-verify-apigw/__tests__/routes/upload.test.mjs @@ -1,119 +1,176 @@ -import {afterEach, jest} from '@jest/globals'; -import supertest from 'supertest'; -import mockfs from 'mock-fs'; - -import { setupServerWithRouter } from '#testutil/testExpressRouter.mjs'; -import multer from 'multer'; +import { afterEach, expect, jest } from "@jest/globals"; +import supertest from "supertest"; +import { setupServerWithRouter } from "#testutil/testExpressRouter.mjs"; describe("Test /upload route", () => { - let server; - let request; - - beforeAll(async() => { - - jest.unstable_mockModule("#lib/testEngineQueue.mjs", () => { - return import("#mocks/lib/testEngineQueue.mjs"); - }); - - jest.unstable_mockModule("#lib/testEngineWorker.mjs", () => { - return import("#mocks/lib/testEngineWorker.mjs"); - }); - - jest.unstable_mockModule("#lib/redisClient.mjs", () => { - return import("#mocks/lib/redisClient.mjs"); - }); - - jest.mock('multer', () => { - const multer = () => ({ - array: jest.fn(() => 'default') - .mockImplementationOnce(() => { - return (req, res, next) => { - req.files = [ - { - fieldname: 'myFiles', - originalname: 'mockdata.sav', - encoding: '7bit', - mimetype: 'application/octet-stream', - destination: '/tmp', - filename: 'mockdata.sav', - path: '/tmp/mockdata.sav', - size: 2195 - }, - ]; - return next(); - }; - }) - .mockImplementationOnce(() => { - return (req, res, next) => { - req.files = [ - { - fieldname: 'myModelFiles', - originalname: 'mockmodel.sav', - encoding: '7bit', - mimetype: 'application/octet-stream', - destination: '/tmp', - filename: 'mockmodel.sav', - path: '/tmp/mockmodel.sav', - size: 132878 - } - ]; - req.body = { - myModelFolders: '', - myModelType: 'Classification' - } - return next(); - }; - }) - }) - multer.diskStorage = () => jest.fn() - multer.memoryStorage = () => jest.fn() - return multer + let server; + let request; + let fs; + let multer; + + beforeAll(async () => { + jest.unstable_mockModule("#lib/testEngineQueue.mjs", () => { + return import("#mocks/lib/testEngineQueue.mjs"); + }); + + jest.unstable_mockModule("#lib/testEngineWorker.mjs", () => { + return import("#mocks/lib/testEngineWorker.mjs"); + }); + + jest.unstable_mockModule("#lib/redisClient.mjs", () => { + return import("#mocks/lib/redisClient.mjs"); + }); + + jest.unstable_mockModule('fs', () => { + const fs = jest.createMockFromModule('node:fs'); + return { + __esModule: true, + default: fs, + readdirSync: fs.readdirSync, + createReadStream: fs.createReadStream, + readFileSync: fs.readFileSync, + createWriteStream: fs.createWriteStream, + writeFile: fs.writeFile, + accessSync: fs.accessSync, + existsSync: fs.existsSync, + mkdirSync: fs.mkdirSync, + copyFileSync: fs.copyFileSync, + unlinkSync: fs.unlinkSync, + statSync: fs.statSync, + } + }); + fs = await import('fs'); + // console.log("fs", fs); + + jest.unstable_mockModule("multer", () => { + const multer = jest.createMockFromModule("multer"); + // console.log("multer", multer) + + // const multer = () => ({ + // array: jest + // .fn() + // .mockImplementationOnce(() => { + // return (req, res, next) => { + // req.files = [ + // { + // fieldname: "myFiles", + // originalname: "mockdata.sav", + // encoding: "7bit", + // mimetype: "application/octet-stream", + // destination: "/tmp", + // filename: "mockdata.sav", + // path: "/tmp/mockdata.sav", + // size: 2195, + // }, + // ]; + // return next(); + // }; + // }) + // .mockImplementationOnce(() => { + // return (req, res, next) => { + // req.files = [ + // { + // fieldname: "myModelFiles", + // originalname: "mockmodel.sav", + // encoding: "7bit", + // mimetype: "application/octet-stream", + // destination: "/tmp", + // filename: "mockmodel.sav", + // path: "/tmp/mockmodel.sav", + // size: 132878, + // }, + // ]; + // req.body = { + // myModelFolders: "", + // myModelType: "Classification", + // }; + // return next(); + // }; + // }), + // }); + // multer.diskStorage = () => jest.fn(); + // multer.memoryStorage = () => jest.fn(); + const array = jest.fn() + .mockReturnValueOnce((req, res, next) => { + req.files = [ + { + fieldname: "myFiles", + originalname: "mockdata.sav", + encoding: "7bit", + mimetype: "application/octet-stream", + destination: "/tmp", + filename: "mockdata.sav", + path: "/tmp/mockdata.sav", + size: 2195, + }, + ]; + return next(); }) - - const router = await import("#routes/upload.mjs"); - const app = setupServerWithRouter("/upload", router.default); - request = supertest(app); - server = app.listen(4012); - - app.use(multer({}).array()); - - }) - - afterAll(done => { - if (server) - server.close(); - done(); - }) - - beforeEach(() => { - jest.clearAllMocks(); - mockfs({ - '/tmp/mockdata.sav': 'mock data content', - '/tmp/mockmodel.sav': 'mock model content', - }); - }) - - afterEach(() => { - mockfs.restore(); - }) - - it("/upload/data should upload dataset file", async () => { - - request.post("/upload/data") - .then( response => { - expect(response.status).toBe(201) + .mockReturnValueOnce((req, res, next) => { + req.files = [ + { + fieldname: "myModelFiles", + originalname: "mockmodel.sav", + encoding: "7bit", + mimetype: "application/octet-stream", + destination: "/tmp", + filename: "mockmodel.sav", + path: "/tmp/mockmodel.sav", + size: 132878, + }, + ]; + req.body = { + myModelFolders: "", + myModelType: "Classification", + }; + return next(); }) - - }) - - it("/upload/model should upload model file", async () => { - - request.post("/upload/model") - .then( response => { - expect(response.status).toBe(201) - }) - - }) - -}) + multer.mockReturnValue({ + array + }) + return { + __esModule: true, + default: multer, + diskStorage: multer.diskStorage, + memoryStorage: multer.memoryStorage, + array: jest.fn(), + }; + }); + multer = await import("multer"); + + const router = await import("#routes/upload.mjs"); + const app = setupServerWithRouter("/upload", router.default); + request = supertest(app); + server = app.listen(4012); + + // app.use(multer({}).array()); + }); + + afterAll((done) => { + if (server) server.close(); + done(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("/upload/data should upload dataset file", async () => { + fs.existsSync.mockReturnValue(true); + fs.statSync.mockReturnValue({}); + fs.readdirSync.mockReturnValue([]); + + const response = await request.post("/upload/data"); + expect(response.status).toBe(201) + }); + + it("/upload/model should upload model file", async () => { + fs.existsSync.mockReturnValue(true); + fs.statSync.mockReturnValue({}); + fs.readdirSync.mockReturnValue([]); + + const response = await request.post("/upload/model"); + expect(response.status).toBe(201) + }); +}); From 8bbeb712e178dbbde416257c2516f8785297f5ea Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 21 Jul 2023 15:08:04 +0800 Subject: [PATCH 038/176] check urlParams defined for paths --- ai-verify-apigw/models/model.model.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ai-verify-apigw/models/model.model.mjs b/ai-verify-apigw/models/model.model.mjs index 2d4406d6f..d81f6d3de 100644 --- a/ai-verify-apigw/models/model.model.mjs +++ b/ai-verify-apigw/models/model.model.mjs @@ -338,6 +338,8 @@ function _exportModelAPI(modelAPI) { } } else if (path_match && path_match.length > 0) { throw new Error("Path parameters not defined"); + } else if (!path_match && (modelAPI.parameters && modelAPI.parameters.paths)) { + throw new Error("urlParams not defined for paths"); } // add query params if any From b0eb862edf4178ed4d273e9dfcb8f0c123448145 Mon Sep 17 00:00:00 2001 From: Leong Peck Yoke Date: Fri, 21 Jul 2023 15:08:20 +0800 Subject: [PATCH 039/176] add tests for graphql api for model api --- .../__tests__/graphql/model.test.mjs | 158 +++++- ai-verify-apigw/testutil/mockData.mjs | 485 +++++++++++------- 2 files changed, 448 insertions(+), 195 deletions(-) diff --git a/ai-verify-apigw/__tests__/graphql/model.test.mjs b/ai-verify-apigw/__tests__/graphql/model.test.mjs index 44a840453..4ef9fc84d 100644 --- a/ai-verify-apigw/__tests__/graphql/model.test.mjs +++ b/ai-verify-apigw/__tests__/graphql/model.test.mjs @@ -1,4 +1,4 @@ -import {jest} from '@jest/globals' +import {expect, jest} from '@jest/globals' import mongoose from 'mongoose'; import casual from '#testutil/mockData.mjs'; @@ -8,6 +8,7 @@ describe("Test Model GraphQL queries and mutations", () => { let ProjectModel; let ModelFileModel; let data = []; + let modelAPIData = []; let projData; beforeAll(async() => { @@ -29,6 +30,13 @@ describe("Test Model GraphQL queries and mutations", () => { data.push(saveDoc.toObject()) } + modelAPIData.push(casual.modelAPI("requestBody", false)); + modelAPIData.push(casual.modelAPI("query", false)); + modelAPIData.push(casual.modelAPI("path", false)); + modelAPIData.push(casual.modelAPI("requestBody", true)); + modelAPIData.push(casual.modelAPI("query", true)); + modelAPIData.push(casual.modelAPI("path", true)); + // ProjectModel = models.ProjectModel; const project = casual.project; project.__t = 'ProjectModel'; @@ -277,4 +285,152 @@ describe("Test Model GraphQL queries and mutations", () => { }) + + it("should create model API", async() => { + const query = ` +mutation($model: ModelAPIInput!) { + createModelAPI(model: $model) { + id + name + description + type + status + modelType + } +} +` + for (let model of modelAPIData) { + const response = await server.executeOperation({ + query, + variables: { + model + } + }) + + // check response + expect(response.body.kind).toBe('single'); + expect(response.body.singleResult.errors).toBeUndefined(); + + const result = response.body.singleResult.data.createModelAPI; + const id = result.id; + model.id = id; + + // check updated into db + const doc = await ModelFileModel.findOne({ _id: mongoose.Types.ObjectId(id) }); + expect(doc).toBeDefined(); + expect(doc.name).toEqual(model.name); + expect(doc.modelType).toEqual(model.modelType); + expect(doc.description).toEqual(model.description); + expect(doc.modelAPI.method).toEqual(model.modelAPI.method); + expect(doc.modelAPI.url).toEqual(model.modelAPI.url); + expect(doc.modelAPI.urlParams).toEqual(model.modelAPI.urlParams); + expect(doc.modelAPI.authType).toEqual(model.modelAPI.authType); + expect(doc.modelAPI.authTypeConfig).toEqual(model.modelAPI.authTypeConfig); + if (model.modelAPI.requestBody) { + expect(model.modelAPI).toHaveProperty("requestBody") + expect(doc.modelAPI.requestBody.mediaType).toEqual(model.modelAPI.requestBody.mediaType); + expect(doc.modelAPI.requestBody.isArray).toEqual(model.modelAPI.requestBody.isArray); + expect(doc.modelAPI.requestBody.maxItems).toEqual(model.modelAPI.requestBody.maxItems); + expect(doc.modelAPI.requestBody.properties.length).toEqual(model.modelAPI.requestBody.properties.length); + for (let i=0; i { + const query = ` +query($modelFileID: ObjectID!) { + getOpenAPISpecFromModel(modelFileID: $modelFileID) +} +` + for (let model of modelAPIData) { + const response = await server.executeOperation({ + query, + variables: { + modelFileID: model.id + } + }) + + // check response + expect(response.body.kind).toBe('single'); + expect(response.body.singleResult.errors).toBeUndefined(); + + const spec = response.body.singleResult.data.getOpenAPISpecFromModel; + expect(spec).toHaveProperty("paths") + const keys = Object.keys(spec.paths) + expect(keys.length).toBe(1); + const path = spec.paths[keys[0]]; + + const method = model.modelAPI.method.toLowerCase(); + expect(path).toHaveProperty(method) + + if (model.modelAPI.requestBody) { + expect(path[method]).toHaveProperty("requestBody") + } else if (model.modelAPI.parameters && model.modelAPI.parameters.paths) { + expect(path[method]).toHaveProperty("parameters") + } else if (model.modelAPI.parameters && model.modelAPI.parameters.queries) { + expect(path[method]).toHaveProperty("parameters") + } + + } + }) + + it("should delete model API", async() => { + const query = ` +mutation($id: ObjectID!) { + deleteModelFile(id: $id) +} +` + for (let model of modelAPIData) { + const count = await ModelFileModel.countDocuments({ _id: mongoose.Types.ObjectId(model.id) }) + expect(count).toBe(1) + + const response = await server.executeOperation({ + query, + variables: { + id: model.id + } + }) + + // check response + expect(response.body.kind).toBe('single'); + expect(response.body.singleResult.errors).toBeUndefined(); + + // verify delete from db + const count2 = await ModelFileModel.countDocuments({ _id: mongoose.Types.ObjectId(model.id) }) + expect(count2).toBe(0) + } + }) + + + }); \ No newline at end of file diff --git a/ai-verify-apigw/testutil/mockData.mjs b/ai-verify-apigw/testutil/mockData.mjs index 2d4472b24..fd2572a5e 100644 --- a/ai-verify-apigw/testutil/mockData.mjs +++ b/ai-verify-apigw/testutil/mockData.mjs @@ -1,115 +1,113 @@ -import { jest } from '@jest/globals'; -import casual from 'casual'; -import mongoose from 'mongoose'; +import { jest } from "@jest/globals"; +import casual from "casual"; +import mongoose from "mongoose"; -casual.define('ObjectId', function() { +casual.define("ObjectId", function () { return mongoose.Types.ObjectId().toString(); -}) +}); -casual.define('randomString', function(len) { - if (typeof len !== 'number') { +casual.define("randomString", function (len) { + if (typeof len !== "number") { len = 128; // default 128; } let str = ""; - for (let i=0; i { + let props = []; + for (let i = 0; i < casual.integer(1, 10); i++) { + props.push({ + [fieldName]: casual.word, + type: casual.random_element(["string", "number", "integer", "boolean"]), + }); + } + return props; + }; + + if (encoding === "requestBody") { + modelAPI.requestBody = { + isArray, + mediaType: casual.random_element([ + "application/x-www-form-urlencoded", + "multipart/form-data", + ]), + properties: randomProperties("field"), + }; + } else if (encoding === "path") { + const pathParams = randomProperties("name"); + modelAPI.parameters = { + paths: { + mediaType: isArray ? "application/json" : "none", + isArray, + pathParams, + }, + }; + if (isArray) { + modelAPI.urlParams = "/{data}" + } else { + modelAPI.urlParams = pathParams.reduce((acc, param) => { + acc += `/{${param.name}}`; + return acc; + }, ""); + } + } else if (encoding === "query") { + modelAPI.parameters = { + queries: { + mediaType: isArray ? "application/json" : "none", + name: "data", // not required for non-array but doesn't matter + isArray, + queryParams: randomProperties("name"), + }, + }; } -}) -casual.define('testDataset', function() { + return { + name: casual.word, + description: casual.short_description, + modelType: casual.random_element(["Classification", "Regression"]), + modelAPI, + }; +}); + +casual.define("testDataset", function () { const d = casual.moment.toDate(); const filename = `${casual.word}.sav`; return { filename, name: filename, - type: 'File', + type: "File", filePath: `/home/test/uploads/data/${filename}`, ctime: d, - description: casual.description, - status: 'Valid', - size: '68.33 KB', - serializer: 'pickle', - dataFormat: 'pandas', - errorMessages: '', + description: casual.short_description, + status: "Valid", + size: "68.33 KB", + serializer: "pickle", + dataFormat: "pandas", + errorMessages: "", dataColumns: [ - { - name: 'age', - datatype: 'int64', - label: 'age', - }, - { - name: 'gender', - datatype: 'int64', - label: 'gender', - }, - { - name: 'race', - datatype: 'int64', - label: 'race', - }, - { - name: 'income', - datatype: 'int64', - label: 'income', - }, - { - name: 'employment', - datatype: 'int64', - label: 'employment', - }, - { - name: 'employment_length', - datatype: 'int64', - label: 'employment_length', - }, - { - name: 'total_donated', - datatype: 'int64', - label: 'total_donated', - }, - { - name: 'num_donation', - datatype: 'int64', - label: 'num_donation', - }, - { - name: 'donation', - datatype: 'int64', - label: 'donation', - }, + { + name: "age", + datatype: "int64", + label: "age", + }, + { + name: "gender", + datatype: "int64", + label: "gender", + }, + { + name: "race", + datatype: "int64", + label: "race", + }, + { + name: "income", + datatype: "int64", + label: "income", + }, + { + name: "employment", + datatype: "int64", + label: "employment", + }, + { + name: "employment_length", + datatype: "int64", + label: "employment_length", + }, + { + name: "total_donated", + datatype: "int64", + label: "total_donated", + }, + { + name: "num_donation", + datatype: "int64", + label: "num_donation", + }, + { + name: "donation", + datatype: "int64", + label: "donation", + }, ], createdAt: d, updatedAt: d, numCols: 6, - numRows: 1235 - } -}) + numRows: 1235, + }; +}); -casual.define('modelAndDatasets', function(modelType) { +casual.define("modelAndDatasets", function (modelType) { let testDataset = casual.testDataset; return { - groundTruthColumn: 'donation', + groundTruthColumn: "donation", model: casual.modelFile, testDataset, groundTruthDataset: testDataset, - } -}) + }; +}); -casual.define('multipleDatasets', function(count) { - if (typeof count !== 'number') { - count=2; +casual.define("multipleDatasets", function (count) { + if (typeof count !== "number") { + count = 2; } let ar = []; - for (let i=0; i Date: Tue, 11 Jul 2023 19:39:30 +0800 Subject: [PATCH 040/176] bare api config screen --- .../src/modules/assets/newModel.tsx | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/ai-verify-portal/src/modules/assets/newModel.tsx b/ai-verify-portal/src/modules/assets/newModel.tsx index 1683491ff..68425abd9 100644 --- a/ai-verify-portal/src/modules/assets/newModel.tsx +++ b/ai-verify-portal/src/modules/assets/newModel.tsx @@ -29,6 +29,12 @@ type OptionCardProps = { children: JSX.Element[]; }; +enum ModelAccess { + MODEL_UPLOAD = 'model-upload', + PIPELINE_UPLOAD = 'pipeline-upload', + API = 'api', +} + function OptionCard(props: OptionCardProps) { const { highlighted, onClick, testid, name, label, icon, children } = props; return ( @@ -60,7 +66,6 @@ function OptionCard(props: OptionCardProps) {
{icon}
{label} - {/*
{tip}
*/}
{children}
@@ -76,7 +81,7 @@ function NewModelOptions({ onBackIconClick?: () => void; }) { const router = useRouter(); - const [mode, setMode] = React.useState(null); + const [mode, setMode] = React.useState(); const [showNewModelUpload, setShowNewModelUpload] = useState(false); const [showNewPipelineUpload, setShowNewPipelineUpload] = useState(false); @@ -99,8 +104,8 @@ function NewModelOptions({ alignItems: 'center', }}> - {/* - Supports -
Any AI Framework.
See: Supported API Configurations

- How It Works -
AI Verify will call the model API to generate predictions for the testing dataset.
-
*/} + }> + Supports +
+ Any AI Framework.
+ See: Supported API Configurations +
+
+
+ How It Works +
+ AI Verify will call the model API to generate predictions for the + testing dataset. +
+
{ setMode(mode); - // console.log("mode is set to: ", mode) }; } function handleNextClick() { if (projectFlow) { - if (mode === 'model-upload') { + if (mode === ModelAccess.MODEL_UPLOAD) { setShowNewModelUpload(true); - } else if (mode === 'pipeline-upload') { + } else if (mode === ModelAccess.PIPELINE_UPLOAD) { setShowNewPipelineUpload(true); } - } else { - if (mode == 'model-upload') { - return router.push('/assets/newModelUpload'); - } else if (mode == 'pipeline-upload') { - return router.push('/assets/newPipelineUpload'); - } - // } else if ( mode == "api") { - // return router.push('/assets/newModelUpload') - // } + return; + } + + if (mode == ModelAccess.MODEL_UPLOAD) { + return router.push('/assets/newModelUpload'); + } else if (mode == ModelAccess.PIPELINE_UPLOAD) { + return router.push('/assets/newPipelineUpload'); + } else if ( mode == "api") { + return router.push('/assets/newModelApiConfig') } } From 718ed8e4ea8098833a33070b2ca3d7a25de22b49 Mon Sep 17 00:00:00 2001 From: aminmdlahir Date: Wed, 12 Jul 2023 12:04:05 +0800 Subject: [PATCH 041/176] api config form and select input components --- ai-verify-portal/package-lock.json | 52 ++ ai-verify-portal/package.json | 1 + .../pages/assets/newModelApiConfig.tsx | 5 + .../src/components/iconButton/index.tsx | 2 +- .../src/components/selectInput/index.tsx | 98 ++++ .../selectInput/styles/selectInput.module.css | 44 ++ .../src/components/textInput/index.tsx | 4 +- .../src/modules/assets/newModelApiConfig.tsx | 520 ++++++++++++++++++ .../styles/newModelApiConfig.module.css | 203 +++++++ 9 files changed, 927 insertions(+), 2 deletions(-) create mode 100644 ai-verify-portal/pages/assets/newModelApiConfig.tsx create mode 100644 ai-verify-portal/src/components/selectInput/index.tsx create mode 100644 ai-verify-portal/src/components/selectInput/styles/selectInput.module.css create mode 100644 ai-verify-portal/src/modules/assets/newModelApiConfig.tsx create mode 100644 ai-verify-portal/src/modules/assets/styles/newModelApiConfig.module.css diff --git a/ai-verify-portal/package-lock.json b/ai-verify-portal/package-lock.json index a8f1e7e06..d822b8b19 100644 --- a/ai-verify-portal/package-lock.json +++ b/ai-verify-portal/package-lock.json @@ -49,6 +49,7 @@ "react-error-boundary": "^4.0.3", "react-grid-layout": "^1.3.4", "react-pdf": "^6.2.1", + "react-select": "^5.7.3", "redis": "^4.5.0", "remark-gfm": "^3.0.1", "remark-mdx-images": "^2.0.0", @@ -2216,6 +2217,19 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/@floating-ui/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.3.1.tgz", + "integrity": "sha512-Bu+AMaXNjrpjh41znzHqaz3r2Nr8hHuHZT6V2LBKMhyMl0FgKA62PNYbqnfgmzOhoWZj70Zecisbo4H1rotP5g==" + }, + "node_modules/@floating-ui/dom": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.4.4.tgz", + "integrity": "sha512-21hhDEPOiWkGp0Ys4Wi6Neriah7HweToKra626CIK712B5m9qkdz54OP9gVldUg+URnBTpv/j/bi/skmGdstXQ==", + "dependencies": { + "@floating-ui/core": "^1.3.1" + } + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.3.0", "hasInstallScript": true, @@ -9152,6 +9166,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/merge-refs": { "version": "1.1.2", "license": "MIT", @@ -10848,6 +10867,26 @@ "react": ">= 16.3" } }, + "node_modules/react-select": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.7.3.tgz", + "integrity": "sha512-z8i3NCuFFWL3w27xq92rBkVI2onT0jzIIPe480HlBjXJ3b5o6Q+Clp4ydyeKrj9DZZ3lrjawwLC5NGl0FSvUDg==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "license": "BSD-3-Clause", @@ -12261,6 +12300,19 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "license": "MIT", diff --git a/ai-verify-portal/package.json b/ai-verify-portal/package.json index f8d673f05..4342a2039 100644 --- a/ai-verify-portal/package.json +++ b/ai-verify-portal/package.json @@ -73,6 +73,7 @@ "react-error-boundary": "^4.0.3", "react-grid-layout": "^1.3.4", "react-pdf": "^6.2.1", + "react-select": "^5.7.3", "redis": "^4.5.0", "remark-gfm": "^3.0.1", "remark-mdx-images": "^2.0.0", diff --git a/ai-verify-portal/pages/assets/newModelApiConfig.tsx b/ai-verify-portal/pages/assets/newModelApiConfig.tsx new file mode 100644 index 000000000..660330587 --- /dev/null +++ b/ai-verify-portal/pages/assets/newModelApiConfig.tsx @@ -0,0 +1,5 @@ +import { NewModelApiConfigModule } from "src/modules/assets/newModelApiConfig"; + +export default function NewModelApiConfigPage() { + return +} \ No newline at end of file diff --git a/ai-verify-portal/src/components/iconButton/index.tsx b/ai-verify-portal/src/components/iconButton/index.tsx index 4b701350a..6a267d02a 100644 --- a/ai-verify-portal/src/components/iconButton/index.tsx +++ b/ai-verify-portal/src/components/iconButton/index.tsx @@ -59,7 +59,7 @@ function IconButton(props: PropsWithChildren) { }} /> ) : null} -
{children}
+
{children}
); diff --git a/ai-verify-portal/src/components/selectInput/index.tsx b/ai-verify-portal/src/components/selectInput/index.tsx new file mode 100644 index 000000000..57e6cd538 --- /dev/null +++ b/ai-verify-portal/src/components/selectInput/index.tsx @@ -0,0 +1,98 @@ +import React, { ChangeEventHandler } from 'react'; +import styles from './styles/selectInput.module.css'; +import Select, { Options } from 'react-select'; + +type SelectOption = { + value: string; + label: string; +}; + +type SelectInputProps = { + name: string; + width?: number; + label?: string; + placeholder?: string; + error?: string; + value?: string; + labelSibling?: React.ReactElement; + options: Options; + onChange?: (option: any) => void; +}; + +function SelectInput(props: SelectInputProps) { + const { + name, + width = 'auto', + label, + placeholder, + error, + value, + labelSibling, + options, + onChange, + } = props; + + return ( +
+