diff --git a/docs/AddUpdateWithAPIArtifact.md b/docs/AddUpdateWithAPIArtifact.md new file mode 100644 index 00000000..65558d8e --- /dev/null +++ b/docs/AddUpdateWithAPIArtifact.md @@ -0,0 +1,113 @@ +Add/Update API with API Artifact +============================== + +This guide describes how to create or update APIs using a single `apiArtifact` upload. + +Supported endpoints +------------------- + +- Create API (org): `POST /devportal/organizations/{organizationID}/apis` +- Create API (S2S): `POST /devportal/apis` +- Update API (org): `PUT /devportal/organizations/{organizationID}/apis/{apiID}` +- Update API (S2S): `PUT /devportal/apis/{apiID}` + +Multipart field +--------------- + +Use the multipart field below: + +- `apiArtifact`: zip file containing metadata, definition, and optional content. + +Recommended artifact structure +------------------------------ + +```text +manifest.yaml # optional +devportal-api.yaml # required (metadata) +devportal-api-definition.yaml # required (API definition) +docs/ # optional (markdown docs) +apiContent/ # optional (landing files + images) +``` + +Optional `manifest.yaml` +------------------------ + +Use this only if your artifact uses non-default paths. + +```yaml +apiMetadataPath: devportal-api.yaml +apiDefinitionPath: devportal-api-definition.yaml +docsPath: docs +apiContentPath: apiContent +``` + +Sample `devportal-api.yaml` +--------------------------- + +```yaml +apiVersion: devportal.api-platform.wso2.com/v1 +kind: RestApi # RestApi | WS | GraphQL | SOAP | WebSubApi + +metadata: + name: pizzashack-api-v1.0 # internal API handle + +spec: + displayName: PizzaShackAPI + version: 1.0.0 + description: This is a simple API for Pizza Shack + provider: WSO2 + # referenceID: + + tags: + - pizza + + labels: + - default + + subscriptionPolicies: + - Gold + - Bronze + + visibility: PRIVATE + visibleGroups: + - HR + + businessInformation: + businessOwner: Jane Roe + businessOwnerEmail: marketing@pizzashack.com + technicalOwner: John Doe + technicalOwnerEmail: architecture@pizzashack.com + + endpoints: + sandboxUrl: https://sand.example.com/pizzashack/v1/api/ + productionUrl: https://prod.example.com/pizzashack/v1/api/ +``` + +Create with artifact (example) +------------------------------ + +```bash +curl --location --request POST 'http://localhost:3000/devportal/organizations/{organizationID}/apis' \ + --header 'Authorization: Bearer ' \ + --form 'apiArtifact=@"{path-to-api-artifact.zip}"' +``` + +Update with artifact (example) +------------------------------ + +```bash +curl --location --request PUT 'http://localhost:3000/devportal/organizations/{organizationID}/apis/{apiID}' \ + --header 'Authorization: Bearer ' \ + --form 'apiArtifact=@"{path-to-api-artifact.zip}"' +``` + +Update behavior summary +----------------------- + +- Metadata is updated from `devportal-api.yaml`. +- API definition is updated from `devportal-api-definition.yaml`. +- Subscription policy mappings are replaced by the artifact values. +- For docs, API landing content, and images: + - if a file/item in the artifact already exists, it is updated + - if it does not exist, it is created + - if it is not included in the artifact, the existing item is kept as-is (not deleted) diff --git a/src/routes/devportalRoute.js b/src/routes/devportalRoute.js index 4c6baf32..e363bd1b 100644 --- a/src/routes/devportalRoute.js +++ b/src/routes/devportalRoute.js @@ -58,6 +58,7 @@ router.post( multipartHandler.fields([ {name: 'apiDefinition', maxCount: 1}, {name: 'schemaDefinition', maxCount: 1}, + {name: 'apiArtifact', maxCount: 1}, ]), apiMetadataService.createAPIMetadata); router.get('/organizations/:orgId/apis/:apiId', enforceSecuirty(constants.SCOPES.DEVELOPER), apiMetadataService.getAPIMetadata); @@ -68,6 +69,7 @@ router.put( multipartHandler.fields([ {name: 'apiDefinition', maxCount: 1}, {name: 'schemaDefinition', maxCount: 1}, + {name: 'apiArtifact', maxCount: 1}, ]), apiMetadataService.updateAPIMetadata); router.delete('/organizations/:orgId/apis/:apiId', enforceSecuirty(constants.SCOPES.DEVELOPER), apiMetadataService.deleteAPIMetadata); @@ -90,6 +92,7 @@ router.post( multipartHandler.fields([ {name: 'apiDefinition', maxCount: 1}, {name: 'schemaDefinition', maxCount: 1}, + {name: 'apiArtifact', maxCount: 1}, ]), apiMetadataService.createAPIMetadata); // s2s applied router.get('/apis', enforceSecuirty(constants.SCOPES.DEVELOPER), apiMetadataService.getAllAPIMetadata); // s2s applied @@ -99,6 +102,7 @@ router.put( multipartHandler.fields([ {name: 'apiDefinition', maxCount: 1}, {name: 'schemaDefinition', maxCount: 1}, + {name: 'apiArtifact', maxCount: 1}, ]), apiMetadataService.updateAPIMetadata); // s2s applied router.delete('/apis/:apiId', enforceSecuirty(constants.SCOPES.DEVELOPER), apiMetadataService.deleteAPIMetadata); // s2s applied diff --git a/src/services/apiMetadataService.js b/src/services/apiMetadataService.js index 678dfecb..505053e5 100644 --- a/src/services/apiMetadataService.js +++ b/src/services/apiMetadataService.js @@ -40,6 +40,15 @@ const createAPIMetadata = async (req, res) => { logger.info('Creating API metadata...', { orgId }); + + if (util.isZipFileUpload(req.files?.apiArtifact?.[0])) { + logger.info('API artifact file detected. Routing through API artifact import flow.', { + orgId, + fileName: req.files.apiArtifact[0].originalname + }); + return importWithAPIArtifact(req, res); + } + const apiMetadata = JSON.parse(req.body.apiMetadata); let apiDefinitionFile, apiFileName = ""; if (req.files?.apiDefinition?.[0]) { @@ -309,6 +318,16 @@ const updateAPIMetadata = async (req, res) => { orgId, apiId }); + + if (util.isZipFileUpload(req.files?.apiArtifact?.[0])) { + logger.info('API artifact file detected. Routing through API artifact update flow.', { + orgId, + apiId, + fileName: req.files.apiArtifact[0].originalname + }); + return updateWithAPIArtifact(req, res); + } + const apiMetadata = JSON.parse(req.body.apiMetadata); let apiDefinitionFile, apiFileName = ""; if (req.files?.apiDefinition?.[0]) { @@ -486,6 +505,585 @@ const deleteAPIMetadata = async (req, res) => { }); }; +const IMPORT_DEFAULTS = constants.API_IMPORT; +const IMPORT_IMAGE_EXTENSIONS = new Set(constants.API_IMPORT.IMAGE_EXTENSIONS); + +const buildImportAPIMetadata = (apiMetadataPayload, apiType) => { + const metadata = apiMetadataPayload?.metadata || {}; + const spec = apiMetadataPayload?.spec || {}; + const businessInfo = spec.businessInformation || {}; + const visibility = (spec.visibility || constants.API_VISIBILITY.PUBLIC).toUpperCase(); + const labels = spec.labels; + const visibleGroups = spec.visibleGroups; + + if (!metadata.name) { + throw new Sequelize.ValidationError("Missing required field: metadata.name in devportal-api.yaml"); + } + if (!spec.displayName) { + throw new Sequelize.ValidationError("Missing required field: spec.displayName in devportal-api.yaml"); + } + if (!spec.version) { + throw new Sequelize.ValidationError("Missing required field: spec.version in devportal-api.yaml"); + } + if (![constants.API_VISIBILITY.PUBLIC, constants.API_VISIBILITY.PRIVATE].includes(visibility)) { + throw new Sequelize.ValidationError("spec.visibility must be either PUBLIC or PRIVATE"); + } + if (labels !== undefined && !Array.isArray(labels)) { + throw new Sequelize.ValidationError("spec.labels must be an array"); + } + if (visibleGroups !== undefined && !Array.isArray(visibleGroups)) { + throw new Sequelize.ValidationError("spec.visibleGroups must be an array"); + } + if (visibility === constants.API_VISIBILITY.PUBLIC && visibleGroups?.length > 0) { + throw new Sequelize.ValidationError( + "Visible groups cannot be specified for a public API" + ); + } + + return { + apiInfo: { + referenceID: spec.referenceID || null, + apiStatus: constants.API_STATUS.PUBLISHED, + provider: spec.provider || "WSO2", + apiName: spec.displayName, + apiHandle: metadata.name, + apiDescription: spec.description || "", + apiVersion: spec.version, + apiType, + visibility, + visibleGroups, + tags: Array.isArray(spec.tags) ? spec.tags : [], + labels: Array.isArray(labels) ? labels : undefined, + owners: { + businessOwner: businessInfo.businessOwner, + businessOwnerEmail: businessInfo.businessOwnerEmail, + technicalOwner: businessInfo.technicalOwner, + technicalOwnerEmail: businessInfo.technicalOwnerEmail, + } + }, + endPoints: { + sandboxURL: spec.endpoints?.sandboxUrl || "", + productionURL: spec.endpoints?.productionUrl || "" + }, + subscriptionPolicies: Array.isArray(spec.subscriptionPolicies) + ? spec.subscriptionPolicies + : [] + }; +}; + +const resolveSubscriptionPolicyMappings = async (orgId, policyNames, apiId, transaction) => { + if (!Array.isArray(policyNames) || policyNames.length === 0) { + return []; + } + + const mappedPolicies = []; + for (const policyName of policyNames) { + if (typeof policyName !== "string" || !policyName.trim()) { + throw new Sequelize.ValidationError("Invalid subscription policy in devportal-api.yaml"); + } + const subscriptionPolicy = await apiDao.getSubscriptionPolicyByName(orgId, policyName, transaction); + if (!subscriptionPolicy) { + throw new Sequelize.EmptyResultError(`Subscription policy not found: ${policyName}`); + } + mappedPolicies.push({ apiID: apiId, policyID: subscriptionPolicy.POLICY_ID }); + } + return mappedPolicies; +}; + +const readApiDefinitionForImport = async (apiDefinitionPath, apiType) => { + const rawContent = await fs.readFile(apiDefinitionPath, constants.CHARSET_UTF8); + if (apiType === constants.API_TYPE.GRAPHQL) { + return { + apiDefinitionFileName: constants.FILE_NAME.API_DEFINITION_GRAPHQL, + apiDefinitionContent: rawContent + }; + } + + const parsedDefinition = util.parseStructuredData(rawContent, path.basename(apiDefinitionPath)); + return { + apiDefinitionFileName: constants.FILE_NAME.API_DEFINITION_FILE_NAME, + apiDefinitionContent: JSON.stringify(parsedDefinition) + }; +}; + +const buildAPIContentFromImport = async (docsPath, apiContentPath) => { + const apiContent = []; + const imageMetadata = {}; + const contentUniquenessCheck = new Set(); + let docsImported = 0; + let apiContentImported = 0; + let imagesImported = 0; + + if (docsPath && await util.fileExists(docsPath)) { + const docsStat = await fs.stat(docsPath); + if (!docsStat.isDirectory()) { + throw new Sequelize.ValidationError("Configured docsPath is not a directory"); + } + const docFiles = await util.readDirectoryFilesRecursively(docsPath); + for (const docFile of docFiles) { + const extension = path.extname(docFile.relativePath).toLowerCase(); + if (extension !== constants.FILE_EXTENSIONS.MD) { + continue; + } + const fileName = path.basename(docFile.relativePath); + const relativeDir = path.dirname(docFile.relativePath).replace(/\\/g, "/"); + const docTypeSuffix = relativeDir === "." ? "Other" : relativeDir; + const docType = `${constants.DOC_TYPES.DOC_ID}${docTypeSuffix}`; + const uniquenessKey = `${docType}:${fileName}`; + if (contentUniquenessCheck.has(uniquenessKey)) { + throw new Sequelize.ValidationError(`Duplicate documentation file found: ${fileName}`); + } + contentUniquenessCheck.add(uniquenessKey); + apiContent.push({ + fileName, + content: await fs.readFile(docFile.absolutePath), + type: docType + }); + docsImported++; + } + } + + if (apiContentPath && await util.fileExists(apiContentPath)) { + const apiContentStat = await fs.stat(apiContentPath); + if (!apiContentStat.isDirectory()) { + throw new Sequelize.ValidationError("Configured apiContentPath is not a directory"); + } + const apiContentFiles = await util.readDirectoryFilesRecursively(apiContentPath); + for (const webFile of apiContentFiles) { + const fileName = path.basename(webFile.relativePath); + const extension = path.extname(fileName).toLowerCase(); + if (IMPORT_IMAGE_EXTENSIONS.has(extension)) { + const uniquenessKey = `${constants.DOC_TYPES.IMAGES}:${fileName}`; + if (contentUniquenessCheck.has(uniquenessKey)) { + throw new Sequelize.ValidationError(`Duplicate apiContent image found: ${fileName}`); + } + contentUniquenessCheck.add(uniquenessKey); + apiContent.push({ + fileName, + content: await fs.readFile(webFile.absolutePath), + type: constants.DOC_TYPES.IMAGES + }); + imageMetadata[path.parse(fileName).name] = fileName; + imagesImported++; + continue; + } + + const uniquenessKey = `${constants.DOC_TYPES.API_LANDING}:${fileName}`; + if (contentUniquenessCheck.has(uniquenessKey)) { + throw new Sequelize.ValidationError(`Duplicate apiContent file found: ${fileName}`); + } + contentUniquenessCheck.add(uniquenessKey); + apiContent.push({ + fileName, + content: await fs.readFile(webFile.absolutePath), + type: constants.DOC_TYPES.API_LANDING + }); + apiContentImported++; + } + } + + return { + apiContent, + imageMetadata, + docsImported, + apiContentImported, + imagesImported, + }; +}; + +const importWithAPIArtifact = async (req, res) => { + const orgId = req.params.orgId; + const uploadedArtifact = req.file || (req.files?.apiArtifact?.[0] || null); + const importId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + let extractPath; + let uploadedArtifactPath = uploadedArtifact?.path; + + logger.info("Importing API artifact", { + orgId, + fileName: uploadedArtifact?.originalname + }); + + try { + if (!orgId) { + throw new Sequelize.ValidationError("Missing required path parameter: orgId"); + } + + if (!uploadedArtifactPath && uploadedArtifact?.buffer) { + const safeFileName = (uploadedArtifact.originalname || 'api-import.zip').replace(/[^a-zA-Z0-9._-]/g, '_'); + uploadedArtifactPath = path.join('/tmp', 'api-import', orgId, `${importId}-${safeFileName}`); + await fs.mkdir(path.dirname(uploadedArtifactPath), { recursive: true }); + await fs.writeFile(uploadedArtifactPath, uploadedArtifact.buffer); + } + + if (!uploadedArtifactPath) { + throw new Sequelize.ValidationError("Missing API artifact file. Use multipart field 'apiArtifact'."); + } + + extractPath = path.join("/tmp", "api-import", orgId, importId); + await fs.mkdir(extractPath, { recursive: true }); + await util.unzipDirectory(uploadedArtifactPath, extractPath); + + const archiveRootPath = await util.getArchiveRootPath(extractPath); + + logger.info("Extracted API artifact", { + archiveRootPath, + extractPath + }); + + const importConfig = { + apiMetadataPath: IMPORT_DEFAULTS.API_METADATA_PATH, + apiDefinitionPath: IMPORT_DEFAULTS.API_DEFINITION_PATH, + docsPath: IMPORT_DEFAULTS.DOCS_PATH, + apiContentPath: IMPORT_DEFAULTS.API_CONTENT_PATH, + }; + + const manifestPath = path.join(archiveRootPath, IMPORT_DEFAULTS.MANIFEST_FILE_NAME); + if (await util.fileExists(manifestPath)) { + const manifestRaw = await fs.readFile(manifestPath, constants.CHARSET_UTF8); + const manifest = util.parseStructuredData(manifestRaw, path.basename(manifestPath)); + + if (manifest.apiMetadataPath && typeof manifest.apiMetadataPath !== 'string') { + throw new Sequelize.ValidationError('manifest.apiMetadataPath must be a string'); + } + if (manifest.apiDefinitionPath && typeof manifest.apiDefinitionPath !== 'string') { + throw new Sequelize.ValidationError('manifest.apiDefinitionPath must be a string'); + } + if (manifest.docsPath && typeof manifest.docsPath !== 'string') { + throw new Sequelize.ValidationError('manifest.docsPath must be a string'); + } + if (manifest.apiContentPath && typeof manifest.apiContentPath !== 'string') { + throw new Sequelize.ValidationError('manifest.apiContentPath must be a string'); + } + + importConfig.apiMetadataPath = manifest.apiMetadataPath || importConfig.apiMetadataPath; + importConfig.apiDefinitionPath = manifest.apiDefinitionPath || importConfig.apiDefinitionPath; + importConfig.docsPath = manifest.docsPath || importConfig.docsPath; + importConfig.apiContentPath = manifest.apiContentPath || importConfig.apiContentPath; + } + + const apiMetadataPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.apiMetadataPath, + IMPORT_DEFAULTS.API_METADATA_PATH + ); + if (!(await util.fileExists(apiMetadataPath))) { + throw new Sequelize.ValidationError(`API metadata file not found: ${importConfig.apiMetadataPath}`); + } + + const apiMetadataRaw = await fs.readFile(apiMetadataPath, constants.CHARSET_UTF8); + const apiMetadataPayload = util.parseStructuredData(apiMetadataRaw, path.basename(apiMetadataPath)); + + const apiType = util.toApiTypeFromKind(apiMetadataPayload.kind); + const importMetadata = buildImportAPIMetadata(apiMetadataPayload, apiType); + const definitionPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.apiDefinitionPath, + IMPORT_DEFAULTS.API_DEFINITION_PATH + ); + + if (!(await util.fileExists(definitionPath))) { + throw new Sequelize.ValidationError(`API definition file not found: ${importConfig.apiDefinitionPath}`); + } + + const docsPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.docsPath, + IMPORT_DEFAULTS.DOCS_PATH + ); + const apiContentPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.apiContentPath, + IMPORT_DEFAULTS.API_CONTENT_PATH + ); + + const { + apiDefinitionFileName, + apiDefinitionContent, + } = await readApiDefinitionForImport(definitionPath, apiType); + + const { + apiContent, + imageMetadata, + docsImported, + apiContentImported, + imagesImported, + } = await buildAPIContentFromImport(docsPath, apiContentPath); + + importMetadata.endPoints.productionURL = changeEndpoint(importMetadata.endPoints.productionURL); + importMetadata.endPoints.sandboxURL = changeEndpoint(importMetadata.endPoints.sandboxURL); + normalizeGraphQLEndpoints(importMetadata); + + let createdApiId; + await sequelize.transaction({ timeout: 60000 }, async (t) => { + const createdAPI = await apiDao.createAPIMetadata(orgId, importMetadata, t); + createdApiId = createdAPI.dataValues.API_ID; + + const mappedPolicies = await resolveSubscriptionPolicyMappings(orgId, + importMetadata.subscriptionPolicies, + createdApiId, + t); + if (mappedPolicies.length > 0) { + await apiDao.createAPISubscriptionPolicy(mappedPolicies, createdApiId, t); + } + + if (importMetadata.apiInfo.labels) { + await apiDao.createAPILabelMapping(orgId, createdApiId, importMetadata.apiInfo.labels, t); + } else { + await apiDao.createAPILabelMapping(orgId, createdApiId, ['default'], t); + } + + await apiDao.storeAPIFile( + apiDefinitionContent, + apiDefinitionFileName, + createdApiId, + constants.DOC_TYPES.API_DEFINITION, + t + ); + + if (Object.keys(imageMetadata).length > 0) { + await apiDao.storeAPIImageMetadata(imageMetadata, createdApiId, t); + } + if (apiContent.length > 0) { + await apiDao.storeAPIFiles(apiContent, createdApiId, t); + } + }); + + res.status(201).send({ + apiID: createdApiId, + apiHandle: importMetadata.apiInfo.apiHandle, + message: "API imported successfully", + imported: { + docs: docsImported, + apiContent: apiContentImported, + images: imagesImported + } + }); + } catch (error) { + logger.error("API import failed", { + orgId, + fileName: uploadedArtifact?.originalname, + error: error.message, + stack: error.stack, + }); + util.handleError(res, error); + } finally { + try { + if (extractPath) { + await fs.rm(extractPath, { recursive: true, force: true }); + } + } catch (_cleanupError) { + logger.warn("Failed to clean import extraction directory", { extractPath }); + } + if (uploadedArtifactPath) { + try { + await fs.unlink(uploadedArtifactPath); + } catch (_cleanupError) { + logger.warn("Failed to clean uploaded import artifact", { uploadedPath: uploadedArtifactPath }); + } + } + } +}; + +const updateWithAPIArtifact = async (req, res) => { + const { orgId, apiId } = req.params; + const uploadedArtifact = req.file || (req.files?.apiArtifact?.[0] || null); + const importId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + let extractPath; + let uploadedArtifactPath = uploadedArtifact?.path; + + logger.info("Updating API with artifact", { + orgId, + apiId, + fileName: uploadedArtifact?.originalname + }); + + try { + if (!orgId || !apiId) { + throw new Sequelize.ValidationError("Missing required path parameters: orgId and apiId"); + } + + if (!uploadedArtifactPath && uploadedArtifact?.buffer) { + const safeFileName = (uploadedArtifact.originalname || 'api-update.zip').replace(/[^a-zA-Z0-9._-]/g, '_'); + uploadedArtifactPath = path.join('/tmp', 'api-import', orgId, `${importId}-${safeFileName}`); + await fs.mkdir(path.dirname(uploadedArtifactPath), { recursive: true }); + await fs.writeFile(uploadedArtifactPath, uploadedArtifact.buffer); + } + + if (!uploadedArtifactPath) { + throw new Sequelize.ValidationError("Missing API artifact file. Use multipart field 'apiArtifact'."); + } + + extractPath = path.join("/tmp", "api-import", orgId, importId); + await fs.mkdir(extractPath, { recursive: true }); + await util.unzipDirectory(uploadedArtifactPath, extractPath); + + const archiveRootPath = await util.getArchiveRootPath(extractPath); + + const importConfig = { + apiMetadataPath: IMPORT_DEFAULTS.API_METADATA_PATH, + apiDefinitionPath: IMPORT_DEFAULTS.API_DEFINITION_PATH, + docsPath: IMPORT_DEFAULTS.DOCS_PATH, + apiContentPath: IMPORT_DEFAULTS.API_CONTENT_PATH, + }; + + const manifestPath = path.join(archiveRootPath, IMPORT_DEFAULTS.MANIFEST_FILE_NAME); + if (await util.fileExists(manifestPath)) { + const manifestRaw = await fs.readFile(manifestPath, constants.CHARSET_UTF8); + const manifest = util.parseStructuredData(manifestRaw, path.basename(manifestPath)); + + if (manifest.apiMetadataPath && typeof manifest.apiMetadataPath !== 'string') { + throw new Sequelize.ValidationError('manifest.apiMetadataPath must be a string'); + } + if (manifest.apiDefinitionPath && typeof manifest.apiDefinitionPath !== 'string') { + throw new Sequelize.ValidationError('manifest.apiDefinitionPath must be a string'); + } + if (manifest.docsPath && typeof manifest.docsPath !== 'string') { + throw new Sequelize.ValidationError('manifest.docsPath must be a string'); + } + if (manifest.apiContentPath && typeof manifest.apiContentPath !== 'string') { + throw new Sequelize.ValidationError('manifest.apiContentPath must be a string'); + } + + importConfig.apiMetadataPath = manifest.apiMetadataPath || importConfig.apiMetadataPath; + importConfig.apiDefinitionPath = manifest.apiDefinitionPath || importConfig.apiDefinitionPath; + importConfig.docsPath = manifest.docsPath || importConfig.docsPath; + importConfig.apiContentPath = manifest.apiContentPath || importConfig.apiContentPath; + } + + const apiMetadataPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.apiMetadataPath, + IMPORT_DEFAULTS.API_METADATA_PATH + ); + if (!(await util.fileExists(apiMetadataPath))) { + throw new Sequelize.ValidationError(`API metadata file not found: ${importConfig.apiMetadataPath}`); + } + + const apiMetadataRaw = await fs.readFile(apiMetadataPath, constants.CHARSET_UTF8); + const apiMetadataPayload = util.parseStructuredData(apiMetadataRaw, path.basename(apiMetadataPath)); + + const apiType = util.toApiTypeFromKind(apiMetadataPayload.kind); + const importMetadata = buildImportAPIMetadata(apiMetadataPayload, apiType); + + const definitionPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.apiDefinitionPath, + IMPORT_DEFAULTS.API_DEFINITION_PATH + ); + if (!(await util.fileExists(definitionPath))) { + throw new Sequelize.ValidationError(`API definition file not found: ${importConfig.apiDefinitionPath}`); + } + + const docsPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.docsPath, + IMPORT_DEFAULTS.DOCS_PATH + ); + const apiContentPath = util.resolvePathInArchive( + archiveRootPath, + importConfig.apiContentPath, + IMPORT_DEFAULTS.API_CONTENT_PATH + ); + + const { + apiDefinitionFileName, + apiDefinitionContent, + } = await readApiDefinitionForImport(definitionPath, apiType); + + const { + apiContent, + imageMetadata, + docsImported, + apiContentImported, + imagesImported, + } = await buildAPIContentFromImport(docsPath, apiContentPath); + + importMetadata.endPoints.productionURL = changeEndpoint(importMetadata.endPoints.productionURL); + importMetadata.endPoints.sandboxURL = changeEndpoint(importMetadata.endPoints.sandboxURL); + normalizeGraphQLEndpoints(importMetadata); + + await sequelize.transaction({ timeout: 60000 }, async (t) => { + const [updatedRows] = await apiDao.updateAPIMetadata(orgId, apiId, importMetadata, t); + if (!updatedRows) { + throw new Sequelize.EmptyResultError("No record found to update"); + } + + if (importMetadata.subscriptionPolicies) { + const mappedPolicies = await resolveSubscriptionPolicyMappings(orgId, + importMetadata.subscriptionPolicies, + apiId, + t); + await apiDao.updateAPISubscriptionPolicy(mappedPolicies, apiId, t); + } + + if (importMetadata.apiInfo.labels !== undefined) { + const existingMetadata = await apiDao.getAPIMetadata(orgId, apiId, t); + const existingLabels = existingMetadata?.[0]?.DP_LABELs + ? existingMetadata[0].DP_LABELs.map((label) => label.dataValues ? label.dataValues.NAME : label.NAME) + : []; + + if (existingLabels.length > 0) { + await apiDao.deleteAPILabels(orgId, apiId, existingLabels, t); + } + if (importMetadata.apiInfo.labels.length > 0) { + await apiDao.createAPILabelMapping(orgId, apiId, importMetadata.apiInfo.labels, t); + } + } + + await apiDao.updateAPIFile( + apiDefinitionContent, + apiDefinitionFileName, + apiId, + orgId, + constants.DOC_TYPES.API_DEFINITION, + t + ); + + if (Object.keys(imageMetadata).length > 0) { + await apiDao.updateAPIImageMetadata(imageMetadata, orgId, apiId, t); + } + if (apiContent.length > 0) { + await apiDao.updateOrCreateAPIFiles(apiContent, apiId, orgId, t); + } + }); + + res.status(200).send({ + apiID: apiId, + apiHandle: importMetadata.apiInfo.apiHandle, + message: "API updated successfully", + imported: { + docs: docsImported, + apiContent: apiContentImported, + images: imagesImported + } + }); + } catch (error) { + logger.error("API artifact update failed", { + orgId, + apiId, + fileName: uploadedArtifact?.originalname, + error: error.message, + stack: error.stack, + }); + util.handleError(res, error); + } finally { + try { + if (extractPath) { + await fs.rm(extractPath, { recursive: true, force: true }); + } + } catch (_cleanupError) { + logger.warn("Failed to clean update extraction directory", { extractPath }); + } + if (uploadedArtifactPath) { + try { + await fs.unlink(uploadedArtifactPath); + } catch (_cleanupError) { + logger.warn("Failed to clean uploaded update artifact", { uploadedPath: uploadedArtifactPath }); + } + } + } +}; + const createAPITemplate = async (req, res) => { logger.info('Creating API template...', { orgId: req.params.orgId, @@ -1345,6 +1943,8 @@ const getViewsFromDB = async (orgId) => { module.exports = { createAPIMetadata, + importWithAPIArtifact, + updateWithAPIArtifact, getAPIMetadata, getAllAPIMetadata, updateAPIMetadata, diff --git a/src/utils/constants.js b/src/utils/constants.js index dfef86ce..6dee4a24 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -44,6 +44,8 @@ module.exports = { UNPUBLISHED: "CREATED" }, API_TYPE: { + REST: "REST", + SOAP: "SOAP", MCP: "MCP", MCP_ONLY: "MCPSERVERSONLY", API_PROXIES: "APISONLY", @@ -74,6 +76,9 @@ module.exports = { HTML: 'text/html', TEXT: 'text/plain', JSON: 'application/json', + ZIP: 'application/zip', + ZIP_COMPRESSED: 'application/x-zip-compressed', + ZIP_MULTIPART: 'multipart/x-zip', YAML: 'application/x-yaml', XML: 'application/xml', CSS: 'text/css', @@ -171,6 +176,14 @@ module.exports = { API_DEFINITION_GRAPHQL: 'apiDefinition.graphql', API_DEFINITION_XML: 'apiDefinition.xml', }, + API_IMPORT: { + MANIFEST_FILE_NAME: './manifest.yaml', + API_METADATA_PATH: './devportal-api.yaml', + API_DEFINITION_PATH: './devportal-api-definition.yaml', + DOCS_PATH: './docs', + API_CONTENT_PATH: './apiContent', + IMAGE_EXTENSIONS: ['.svg', '.jpg', '.jpeg', '.png', '.gif'] + }, DEFAULT_SUBSCRIPTION_PLANS: [ { "policyName": "Bronze", diff --git a/src/utils/util.js b/src/utils/util.js index 216b13b6..172a7468 100644 --- a/src/utils/util.js +++ b/src/utils/util.js @@ -34,6 +34,7 @@ const { Sequelize } = require('sequelize'); const apiDao = require('../dao/apiMetadata'); const subscriptionPolicyDTO = require('../dto/subscriptionPolicy'); const jwt = require('jsonwebtoken'); +const yaml = require('js-yaml'); const filePrefix = '/src/defaultContent/'; // Function to load and convert markdown file to HTML @@ -259,6 +260,38 @@ const unzipDirectory = async (zipPath, extractPath) => { }); } +// Detect whether an uploaded multipart file is a ZIP artifact. +function isZipFileUpload(file) { + if (!file) { + return false; + } + + const fileName = (file.originalname || '').toLowerCase(); + const mimeType = (file.mimetype || '').toLowerCase(); + return fileName.endsWith('.zip') + || mimeType === constants.MIME_TYPES.ZIP + || mimeType === constants.MIME_TYPES.ZIP_COMPRESSED + || mimeType === constants.MIME_TYPES.ZIP_MULTIPART; +} + +// Map imported API `kind` values to internal API type constants. +function toApiTypeFromKind(kind) { + switch ((kind || '').toLowerCase()) { + case 'restapi': + return constants.API_TYPE.REST; + case 'ws': + return constants.API_TYPE.WS; + case 'graphql': + return constants.API_TYPE.GRAPHQL; + case 'soap': + return constants.API_TYPE.SOAP; + case 'websubapi': + return constants.API_TYPE.WEBSUB; + default: + throw new Sequelize.ValidationError(`Unsupported API kind: ${kind}`); + } +} + const imageMapping = { [constants.FILE_EXTENSIONS.SVG]: constants.MIME_TYPES.SVG, [constants.FILE_EXTENSIONS.JPG]: constants.MIME_TYPES.JPEG, @@ -657,6 +690,95 @@ const rejectExtraProperties = (allowedKeys, payload) => { return extraKeys; }; +// Returns true when a file or directory exists on disk. +const fileExists = async (targetPath) => { + try { + await fs.promises.access(targetPath); + return true; + } catch (_error) { + return false; + } +}; + +// Checks whether a target path is inside a given base directory. +const isPathWithinBase = (basePath, targetPath) => { + const normalizedBasePath = path.resolve(basePath); + const normalizedTargetPath = path.resolve(targetPath); + return normalizedTargetPath === normalizedBasePath + || normalizedTargetPath.startsWith(`${normalizedBasePath}${path.sep}`); +}; + +// Resolves a configured (or default) archive-relative path and blocks path traversal. +const resolvePathInArchive = (basePath, configuredPath, defaultPath) => { + const relativePath = configuredPath || defaultPath; + const resolvedPath = path.resolve(basePath, relativePath); + if (!isPathWithinBase(basePath, resolvedPath)) { + throw new Sequelize.ValidationError(`Invalid import path: ${relativePath}. Path must stay within the extracted archive.`); + } + return resolvedPath; +}; + +// Detects if the archive was created with a single top-level folder (e.g., MyAPI/) and returns that as the root. +const getArchiveRootPath = async (extractedPath) => { + const entries = await fs.promises.readdir(extractedPath, { withFileTypes: true }); + const visibleEntries = entries.filter((entry) => entry.name !== '.DS_Store' && entry.name !== '__MACOSX'); + + if (visibleEntries.length === 1 && visibleEntries[0].isDirectory()) { + return path.join(extractedPath, visibleEntries[0].name); + } + + return extractedPath; +}; + +// Recursively reads files from a directory while rejecting traversal and symbolic links. +const readDirectoryFilesRecursively = async (directory, baseDirectory = directory) => { + const entries = await fs.promises.readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + if (entry.name === '.DS_Store') { + continue; + } + + const absolutePath = path.resolve(path.join(directory, entry.name)); + if (!isPathWithinBase(baseDirectory, absolutePath)) { + throw new Sequelize.ValidationError(`Invalid file path in archive: ${entry.name}`); + } + + const stat = await fs.promises.lstat(absolutePath); + if (stat.isSymbolicLink()) { + throw new Sequelize.ValidationError(`Symbolic links are not allowed in archive: ${entry.name}`); + } + + if (entry.isDirectory()) { + files.push(...await readDirectoryFilesRecursively(absolutePath, baseDirectory)); + } else { + files.push({ + absolutePath, + relativePath: path.relative(baseDirectory, absolutePath).replace(/\\/g, '/') + }); + } + } + return files; +}; + +// Parses JSON first, then YAML, and returns a plain object payload. +const parseStructuredData = (rawContent, sourceName = 'content') => { + try { + return JSON.parse(rawContent); + } catch (_jsonError) { + try { + const parsed = yaml.load(rawContent); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Parsed content is not a valid object'); + } + return parsed; + } catch (yamlError) { + throw new Sequelize.ValidationError(`Failed to parse ${sourceName}: ${yamlError.message}`); + } + } +}; + async function readFilesInDirectory(directory, orgId, protocol, host, viewName, baseDir = '') { try { const files = await fs.promises.readdir(directory, { withFileTypes: true }); @@ -949,6 +1071,8 @@ module.exports = { renderTemplateFromAPI, renderGivenTemplate, handleError, + isZipFileUpload, + toApiTypeFromKind, retrieveContentType, getAPIFileContent, getAPIImages, @@ -962,6 +1086,11 @@ module.exports = { getErrors, validateProvider, validateRequestParameters, + parseStructuredData, + fileExists, + resolvePathInArchive, + getArchiveRootPath, + readDirectoryFilesRecursively, rejectExtraProperties, readFilesInDirectory, appendAPIImageURL,