diff --git a/src/controllers/model.controller.js b/src/controllers/model.controller.js index c7b173559aa7830c20a4e7911852e682b50f440d..f52b4164568e07e7be1c4f663c63d7c2da4dd06a 100644 --- a/src/controllers/model.controller.js +++ b/src/controllers/model.controller.js @@ -35,6 +35,7 @@ const createModel = catchAsync(async (req, res) => { type: api.type, datatype: api.datatype, isWishlist: api.isWishlist || false, + unit: api.unit, }) ) ); @@ -64,6 +65,7 @@ const createModel = catchAsync(async (req, res) => { type: api.type || 'branch', datatype: api.datatype || (api.type !== 'branch' ? 'string' : null), isWishlist: api.isWishlist || false, + unit: api.unit, }) ) ); @@ -105,16 +107,18 @@ const listAllModels = catchAsync(async (req, res) => { req.user?.id ); - const contributedModels = await modelService.queryModels( - { - is_contributor: req.user?.id, - }, - { - limit: 1000, - }, - {}, - req.user?.id - ); + const contributedModels = req.user?.id + ? await modelService.queryModels( + {}, + { + limit: 1000, + }, + { + is_contributor: req.user?.id, + }, + req.user?.id + ) + : { results: [] }; const publicReleasedModels = await modelService.queryModels( { @@ -241,6 +245,68 @@ const getComputedVSSApi = catchAsync(async (req, res) => { res.send(data); }); +const getApiDetail = catchAsync(async (req, res) => { + if (!(await permissionService.canAccessModel(req.user?.id, req.params.id))) { + throw new ApiError(httpStatus.FORBIDDEN, 'Forbidden'); + } + const api = await apiService.getApiDetail(req.params.id, req.params.apiName); + if (!api) { + throw new ApiError(httpStatus.NOT_FOUND, 'Api not found'); + } + res.send(api); +}); + +const replaceApi = catchAsync(async (req, res) => { + const modelId = req.params.id; + const { extended_apis, api_version, main_api } = await modelService.processApiDataUrl(req.body.api_data_url); + + const updateBody = { + custom_apis: [], // Remove all custom_apis + main_api, + api_version: null, + }; + if (api_version) { + updateBody.api_version = api_version; + } + + // Validate extended_apis + if (Array.isArray(extended_apis)) { + for (const extended_api of extended_apis) { + const error = await extendedApiService.validateExtendedApi({ + ...extended_api, + model: modelId, + }); + if (error) { + throw new ApiError( + httpStatus.BAD_REQUEST, + `Error in validating extended API ${extended_api.name || extended_api.apiName} - ${error.details.join(', ')}` + ); + } + } + } + + await modelService.updateModelById(modelId, updateBody, req.user?.id); + await extendedApiService.deleteExtendedApisByModelId(modelId); + + await Promise.all( + (extended_apis || []).map((api) => + extendedApiService.createExtendedApi({ + model: modelId, + apiName: api.apiName, + description: api.description, + skeleton: api.skeleton, + tags: api.tags, + type: api.type, + datatype: api.datatype, + isWishlist: api.isWishlist || false, + unit: api.unit, + }) + ) + ); + + res.status(httpStatus.OK).send(); +}); + module.exports = { createModel, listModels, @@ -251,4 +317,6 @@ module.exports = { deleteAuthorizedUser, getComputedVSSApi, listAllModels, + getApiDetail, + replaceApi, }; diff --git a/src/models/prototype.model.js b/src/models/prototype.model.js index e172d3b430b18712e59a0ffc1a45c62570881b06..0c14d3aa3974fa38994ccd5a636222a9cc22237a 100644 --- a/src/models/prototype.model.js +++ b/src/models/prototype.model.js @@ -201,6 +201,10 @@ const prototypeSchema = mongoose.Schema( flow: { type: mongoose.SchemaTypes.Mixed, }, + editors_choice: { + type: Boolean, + default: false, + }, }, { timestamps: true, diff --git a/src/routes/v2/model.route.js b/src/routes/v2/model.route.js index 4888c6543daec90a7bee76add9b48dbda1c07d0c..c0929f30eb8932696a84e2a58fdadbd5a9a4d197 100644 --- a/src/routes/v2/model.route.js +++ b/src/routes/v2/model.route.js @@ -50,6 +50,10 @@ router modelController.deleteModel ); +router + .route('/:id/replace-api') + .post(auth(), checkPermission(PERMISSIONS.WRITE_MODEL), validate(modelValidation.replaceApi), modelController.replaceApi); + router.route('/:id/api').get( auth({ optional: !config.strictAuth, @@ -58,6 +62,8 @@ router.route('/:id/api').get( modelController.getComputedVSSApi ); +router.route('/:id/api/:apiName').get(auth({ optional: !config.strictAuth }), modelController.getApiDetail); + router .route('/:id/permissions') .post( diff --git a/src/services/api.service.js b/src/services/api.service.js index 3ed36a9c78b6ced3a5ed029753c6af188faebffa..89d7ca98985c34ea98294070ea46ee8b1d0f4118 100644 --- a/src/services/api.service.js +++ b/src/services/api.service.js @@ -6,6 +6,8 @@ const fs = require('fs'); const path = require('path'); const logger = require('../config/logger'); const { sortObject } = require('../utils/sort'); +const _ = require('lodash'); +const crypto = require('crypto'); /** * @@ -184,6 +186,35 @@ const getUsedApis = (code, apiList) => { } }; +const ensureParentApiHierarchy = (root, api) => { + if (!api) return root; + + let parentNode = root; + + for (const currentApi of api.split('.')) { + parentNode.children ??= {}; + + parentNode = parentNode.children[currentApi] ??= { + type: 'branch', + children: {}, + }; + } + + parentNode.children ??= {}; + + return parentNode; +}; + +const traverse = (api, callback, prefix = '') => { + if (!api) return; + if (api.children) { + for (const [key, child] of Object.entries(api.children)) { + traverse(child, callback, `${prefix}.${key}`); + } + } + callback(api, prefix); +}; + /** * * @param {string} modelId @@ -211,22 +242,31 @@ const computeVSSApi = async (modelId) => { const extendedApis = await ExtendedApi.find({ model: modelId, - isWishlist: true, }); extendedApis.forEach((extendedApi) => { try { const name = extendedApi.apiName.split('.').slice(1).join('.'); if (!name) return; - ret[mainApi].children[name] = { - description: extendedApi.description, - type: extendedApi.type || 'branch', - id: extendedApi._id, - datatype: extendedApi.datatype, - name: extendedApi.apiName, - isWishlist: extendedApi.isWishlist, - }; - if (extendedApi.unit) { - ret[mainApi].children[name].unit = extendedApi.unit; + + // Only add the extended API if it doesn't exist in the current CVI + const keys = name.split('.'); + let current = ret[mainApi].children; + for (const key of keys) { + if (!current || !current[key]) { + ret[mainApi].children[name] = { + description: extendedApi.description, + type: extendedApi.type || 'branch', + id: extendedApi._id, + datatype: extendedApi.datatype, + name: extendedApi.apiName, + isWishlist: extendedApi.isWishlist, + }; + if (extendedApi.unit) { + ret[mainApi].children[name].unit = extendedApi.unit; + } + break; + } + current = current[key].children; } } catch (error) { logger.warn(`Error while processing extended API ${extendedApi._id} with name ${extendedApi.apiName}: ${error}`); @@ -240,18 +280,70 @@ const computeVSSApi = async (modelId) => { } // Nest parent/children apis - const len = Object.keys(ret[mainApi].children).length; - for (let i = len - 1; i >= 0; i--) { - const key = Object.keys(ret[mainApi].children)[i]; - const parent = key.split('.').slice(0, -1).join('.'); - if (parent && ret[mainApi].children[parent]) { - ret[mainApi].children[parent].children = ret[mainApi].children[parent].children || {}; - const childKey = key.replace(`${parent}.`, ''); - ret[mainApi].children[parent].children[childKey] = ret[mainApi].children[key]; - delete ret[mainApi].children[key]; - } + const keys = Object.keys(ret[mainApi]?.children || {}).filter((key) => key.includes('.')); + + for (const key of keys) { + const parts = key.split('.'); + const parent = parts.slice(0, -1).join('.'); + const childKey = parts[parts.length - 1]; + + const parentNode = ensureParentApiHierarchy(ret[mainApi], parent); + + parentNode.children[childKey] = { + ...ret[mainApi].children[key], + children: ret[mainApi].children[key].children || {}, + }; + + delete ret[mainApi].children[key]; } + // Refine tree + traverse( + ret[mainApi], + (node, prefix) => { + // Delete empty children + if (_.isEmpty(node.children)) { + delete node.children; + } + // Ensure name and id + if (!node.name) { + node.name = prefix; + } + if (!node.id) { + node.id = crypto.randomBytes(12).toString('hex'); + } + if (!node.description) { + node.description = 'nan'; + } + // Ensure datatype + if (node.type === 'branch') { + delete node.datatype; + } else if (!node.datatype) { + node.datatype = 'string'; + } + }, + mainApi + ); + + return ret; +}; + +const getApiDetail = async (modelId, apiName) => { + const tree = await computeVSSApi(modelId); + + const mainApi = Object.keys(tree)[0] || 'Vehicle'; + let ret = null; + + traverse( + tree[mainApi], + (api, prefix) => { + if (prefix === apiName || api?.name === apiName || api?.apiName == apiName) { + ret = api; + } + }, + mainApi + ); + return ret; }; @@ -265,3 +357,5 @@ module.exports.getVSSVersion = getVSSVersion; module.exports.computeVSSApi = computeVSSApi; module.exports.parseCvi = parseCvi; module.exports.getUsedApis = getUsedApis; +module.exports.getApiDetail = getApiDetail; +module.exports.traverse = traverse; diff --git a/src/services/extendedApi.service.js b/src/services/extendedApi.service.js index 264bb5c1f4016eace709720b70670253d2287df5..8adb34c049de8cf8ef4c13c10fcd69382a62f79b 100644 --- a/src/services/extendedApi.service.js +++ b/src/services/extendedApi.service.js @@ -3,6 +3,8 @@ const { ExtendedApi } = require('../models'); const ApiError = require('../utils/ApiError'); const { permissionService } = require('.'); const { PERMISSIONS } = require('../config/roles'); +const Joi = require('joi'); +const { extendedApiValidation } = require('../validations'); /** * Create a new ExtendedApi @@ -82,11 +84,34 @@ const getExtendedApiByApiNameAndModel = async (apiName, model) => { return ExtendedApi.findOne({ apiName, model }); }; -module.exports = { - createExtendedApi, - queryExtendedApis, - getExtendedApiById, - updateExtendedApiById, - deleteExtendedApiById, - getExtendedApiByApiNameAndModel, +const deleteExtendedApisByModelId = async (modelId) => { + await ExtendedApi.deleteMany({ model: modelId }); }; + +/** + * + * @param {import('../typedefs').ExtendedApi} extendedApi + * @returns {Promise<null | {details: string[]}>} + */ +const validateExtendedApi = async (extendedApi) => { + const { _, error } = Joi.compile(extendedApiValidation.createExtendedApi.body.unknown()) + .prefs({ errors: { label: 'key' }, abortEarly: false }) + .validate(extendedApi); + + if (error) { + return { + details: error.details.map((details) => details.message), + }; + } + + return null; +}; + +module.exports.createExtendedApi = createExtendedApi; +module.exports.queryExtendedApis = queryExtendedApis; +module.exports.getExtendedApiById = getExtendedApiById; +module.exports.updateExtendedApiById = updateExtendedApiById; +module.exports.deleteExtendedApiById = deleteExtendedApiById; +module.exports.getExtendedApiByApiNameAndModel = getExtendedApiByApiNameAndModel; +module.exports.deleteExtendedApisByModelId = deleteExtendedApisByModelId; +module.exports.validateExtendedApi = validateExtendedApi; diff --git a/src/services/model.service.js b/src/services/model.service.js index 52bd38c45263b2c6b9c45b3ba36c1b1d5996153b..84b82dfe4110b3fe7a1aa4f98bc38e46a69e8358 100644 --- a/src/services/model.service.js +++ b/src/services/model.service.js @@ -446,7 +446,7 @@ const traverse = (api, callback, prefix = '') => { /** * * @param {string} apiDataUrl - * @returns {Promise<{api_version: string; extended_apis: any[]} | undefined>} + * @returns {Promise<{main_api: string; api_version: string; extended_apis: any[]} | undefined>} */ const processApiDataUrl = async (apiDataUrl) => { try { @@ -462,7 +462,13 @@ const processApiDataUrl = async (apiDataUrl) => { (api, prefix) => { for (const [key, value] of Object.entries(api.children || {})) { if (value.isWishlist) { - extendedApis.push(convertToExtendedApiFormat(value)); + const name = value?.name || `${prefix}.${key}`; + extendedApis.push( + convertToExtendedApiFormat({ + ...value, + name, + }) + ); delete api.children[key]; } } @@ -491,7 +497,13 @@ const processApiDataUrl = async (apiDataUrl) => { data[mainApi], (api, prefix) => { for (const [key, value] of Object.entries(api.children || {})) { - extendedApis.push(convertToExtendedApiFormat(value)); + const name = value?.name || `${prefix}.${key}`; + extendedApis.push( + convertToExtendedApiFormat({ + ...value, + name, + }) + ); delete api.children[key]; } }, @@ -505,7 +517,11 @@ const processApiDataUrl = async (apiDataUrl) => { return result; } catch (error) { - logger.warn(`Error in processing api data url: ${error}`); + logger.error(`Error in processing api data: ${error}`); + throw new ApiError( + httpStatus.BAD_REQUEST, + error?.message || `Error in processing api data. Please check content of the file again.` + ); } }; diff --git a/src/services/prototype.service.js b/src/services/prototype.service.js index d2c331cbfd9bcbceef68d7af3f6df702a1f3ec26..b3efcd9d348c3ebe942e42079e76a644e565a0a2 100644 --- a/src/services/prototype.service.js +++ b/src/services/prototype.service.js @@ -66,7 +66,13 @@ const bulkCreatePrototypes = async (userId, prototypes) => { * @returns {Promise<QueryResult>} */ const queryPrototypes = async (filter, options) => { - const prototypes = await Prototype.paginate(filter, options); + const prototypes = await Prototype.paginate(filter, { + ...options, + // Default sort by editors_choice and createdAt + sortBy: options?.sortBy + ? ['editors_choice:desc,createdAt:asc', options.sortBy].join(',') + : 'editors_choice:desc,createdAt:asc', + }); return prototypes; }; diff --git a/src/services/search.service.js b/src/services/search.service.js index baa9caa9745fec1c85617f427346609b50709248..0f574864439a7fe58fba81a6629416812c5527d3 100644 --- a/src/services/search.service.js +++ b/src/services/search.service.js @@ -21,7 +21,13 @@ const search = async (query, options, userId) => { }, ], }, - options + { + ...options, + // Default sort by editors_choice and createdAt + sortBy: options?.sortBy + ? ['editors_choice:desc,createdAt:asc', options.sortBy].join(',') + : 'editors_choice:desc,createdAt:asc', + } ); return { @@ -45,7 +51,10 @@ const searchUserByEmail = async (email) => { * @param {string} signal */ const searchPrototypesBySignal = async (signal) => { - const prototypes = await Prototype.find().select('model_id code name image_file').populate('model_id'); + const prototypes = await Prototype.find() + .select('model_id code name image_file') + .sort('-editors_choice createdAt') + .populate('model_id'); return prototypes.filter((prototype) => prototype.code?.includes(signal)); }; diff --git a/src/typedefs/index.js b/src/typedefs/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b204b09a96e0319136d699b52a2af4bcf060b7ee --- /dev/null +++ b/src/typedefs/index.js @@ -0,0 +1,13 @@ +/** + * @typedef ExtendedApi + * @property apiName: string + * @property model: string + * @property skeleton: string + * @property unit: string + * @property type: string + * @property datatype: string + * @property description: string + * @property isWishlist: boolean + */ + +exports.unused = {}; diff --git a/src/validations/extendedApi.validation.js b/src/validations/extendedApi.validation.js index 8fc0b4437c724b6dbb9b3a687ac34ef5d44ee180..ac6f56e7d60ec7eebad34609149bcc461b31a542 100644 --- a/src/validations/extendedApi.validation.js +++ b/src/validations/extendedApi.validation.js @@ -7,7 +7,11 @@ const createExtendedApi = { model: Joi.string().custom(objectId).required(), skeleton: Joi.string().optional(), type: Joi.string(), - datatype: Joi.string(), + datatype: Joi.alternatives().conditional('type', { + is: 'branch', + then: Joi.string().allow(null).optional(), + otherwise: Joi.string().required(), + }), description: Joi.string().allow('').default(''), tags: Joi.array().items( Joi.object().keys({ @@ -16,7 +20,7 @@ const createExtendedApi = { }) ), isWishlist: Joi.boolean().default(false), - unit: Joi.string(), + unit: Joi.string().allow('', null), }), }; @@ -47,7 +51,11 @@ const updateExtendedApi = { .message('apiName must start with Vehicle.'), skeleton: Joi.string().optional(), type: Joi.string(), - datatype: Joi.string(), + datatype: Joi.alternatives().conditional('type', { + is: 'branch', + then: Joi.string().allow(null).optional(), + otherwise: Joi.string().required(), + }), description: Joi.string().allow(''), tags: Joi.array().items( Joi.object().keys({ @@ -56,6 +64,7 @@ const updateExtendedApi = { }) ), isWishlist: Joi.boolean(), + unit: Joi.string().allow('', null), }) .min(1), }; diff --git a/src/validations/model.validation.js b/src/validations/model.validation.js index 47b1f5beccfe75f008f2e2c75a15ea0f1ca576bc..47b9e3f6c352bf315416b0c08d20c0ca7ed27404 100644 --- a/src/validations/model.validation.js +++ b/src/validations/model.validation.js @@ -118,6 +118,15 @@ const getApiByModelId = { }), }; +const replaceApi = { + params: Joi.object().keys({ + id: Joi.string().custom(objectId), + }), + body: Joi.object().keys({ + api_data_url: Joi.string().required(), + }), +}; + module.exports = { createModel, listModels, @@ -127,4 +136,5 @@ module.exports = { addAuthorizedUser, deleteAuthorizedUser, getApiByModelId, + replaceApi, }; diff --git a/src/validations/prototype.validation.js b/src/validations/prototype.validation.js index 2e83c2c6b67c8649d255055354c0992fe22c33ed..bf3cf583a236f84095ba24182723053ccffa7002 100644 --- a/src/validations/prototype.validation.js +++ b/src/validations/prototype.validation.js @@ -43,6 +43,7 @@ const bodyValidation = Joi.object().keys({ partner_logo: Joi.string().allow(''), language: Joi.string().default('python'), requirements: Joi.string().allow(''), + editors_choice: Joi.boolean(), }); const createPrototype = { @@ -116,6 +117,7 @@ const updatePrototype = { partner_logo: Joi.string().allow(''), requirements: Joi.string().allow(''), language: Joi.string(), + editors_choice: Joi.boolean(), // rated_by: Joi.object().pattern( // /^[0-9a-fA-F]{24}$/, // Joi.object()