diff --git a/build-libs/__tests__/get-latest-content-sha-for-product.test.ts b/build-libs/__tests__/get-latest-content-sha-for-product.test.ts new file mode 100644 index 0000000000..d345d39682 --- /dev/null +++ b/build-libs/__tests__/get-latest-content-sha-for-product.test.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import getLatestContentShaForProduct from '../get-latest-content-sha-for-product' +import fetchGithubFile from '@build-libs/fetch-github-file' +import { PRODUCT_REDIRECT_ENTRIES } from '@build-libs/redirects' + +describe('getLatestContentShaForProduct', () => { + PRODUCT_REDIRECT_ENTRIES.forEach(({ repo, path }) => { + it(`fetches the latest SHA for the "${repo}" repo`, async () => { + const latestSha = await getLatestContentShaForProduct(repo) + expect(typeof latestSha).toBe('string') + }) + + if (['hcp-docs', 'sentinel', 'ptfe-releases'].includes(repo)) { + console.log(`Skipping test for private repo "${repo}"`) + } else { + it(`fetches the latest SHA for the "${repo}" repo, then validates the SHA by fetching redirects`, async () => { + const latestSha = await getLatestContentShaForProduct(repo) + expect(typeof latestSha).toBe('string') + const redirectsFileString = await fetchGithubFile({ + owner: 'hashicorp', + repo: repo, + path: path, + ref: latestSha, + }) + expect(typeof redirectsFileString).toBe('string') + }) + } + }) +}) diff --git a/build-libs/get-latest-content-sha-for-product.js b/build-libs/get-latest-content-sha-for-product.js new file mode 100644 index 0000000000..6ea83fe127 --- /dev/null +++ b/build-libs/get-latest-content-sha-for-product.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * We're using the docs endpoints to fetch the latest SHA, so we use + * the env var for the docs API. + */ +const MKTG_CONTENT_DOCS_API = process.env.MKTG_CONTENT_DOCS_API + +/** + * A map of all possible `product` slugs to known content API endpoints that + * will return an object with a `ref` property that accurately reflects the + * ref from which the latest content was uploaded. + */ +const KNOWN_LATEST_REF_ENDPOINTS = { + boundary: '/api/content/boundary/nav-data/latest/docs', + nomad: '/api/content/nomad/nav-data/latest/docs', + vault: '/api/content/vault/nav-data/latest/docs', + vagrant: '/api/content/vagrant/nav-data/latest/docs', + packer: '/api/content/packer/nav-data/latest/docs', + consul: '/api/content/consul/nav-data/latest/docs', + 'terraform-docs-common': + '/api/content/terraform-docs-common/nav-data/latest/docs', + 'hcp-docs': '/api/content/hcp-docs/nav-data/latest/docs', + 'ptfe-releases': '/api/content/ptfe-releases/nav-data/latest/enterprise', + sentinel: '/api/content/sentinel/nav-data/latest/sentinel', +} + +/** + * Fetch the latest sha from the content API for a given product. + * This relies on known `nav-data` endpoints for each product. + * + * @param {string} product + * @returns {Promise} + */ +async function getLatestContentShaForProduct(product) { + const contentUrl = new URL(MKTG_CONTENT_DOCS_API) + const knownEndpoint = KNOWN_LATEST_REF_ENDPOINTS[product] + if (!knownEndpoint) { + throw new Error( + `getLatestContentShaForProduct failed, with unknown product: ${product}. Please add a known endpoint for this product to KNOWN_LATEST_REF_ENDPOINTS.` + ) + } + contentUrl.pathname = knownEndpoint + const latestSha = await fetch(contentUrl.toString()) + .then((resp) => resp.json()) + .then((json) => json.result.sha) + return latestSha +} + +module.exports = getLatestContentShaForProduct diff --git a/build-libs/redirects.js b/build-libs/redirects.js index d39addf36f..8158cbff3e 100644 --- a/build-libs/redirects.js +++ b/build-libs/redirects.js @@ -10,6 +10,7 @@ const path = require('path') const { isDeployPreview } = require('../src/lib/env-checks') const fetchGithubFile = require('./fetch-github-file') +const getLatestContentShaForProduct = require('./get-latest-content-sha-for-product') const { getTutorialRedirects } = require('./tutorial-redirects') const { getDocsDotHashiCorpRedirects, @@ -26,21 +27,6 @@ const HOSTNAME_MAP = { 'test-st.hashi-mktg.com': 'sentinel', } -/** - * Fetch the latest ref from the content API to ensure the redirects are accurate. - * - * @TODO save the redirects to the content database and expose them directly via the API - */ -async function getLatestContentRefForProduct(product) { - const contentUrl = new URL('https://content.hashicorp.com') - contentUrl.pathname = `/api/content/${product}/version-metadata/latest` - const latestRef = await fetch(contentUrl.toString()) - .then((resp) => resp.json()) - .then((json) => json.result.ref) - - return latestRef -} - /** * Load redirects from a content repository. * @@ -63,13 +49,10 @@ async function getLatestContentRefForProduct(product) { * redirects and return early with an empty array. * * @param {string} repoName The name of the repo, owner is always `hashicorp`. - * @param {string?} redirectsPath Optional, custom path to the redirects file. + * @param {string} redirectsPath Path within the repo to the redirects file. * @returns {Promise} */ -async function getRedirectsFromContentRepo( - repoName, - redirectsPath = 'website/redirects.js' -) { +async function getRedirectsFromContentRepo(repoName, redirectsPath) { /** * Note: These constants are declared for clarity in build context intent. */ @@ -82,12 +65,12 @@ async function getRedirectsFromContentRepo( let redirectsFileString if (isDeveloperBuild) { // For `hashicorp/dev-portal` builds, load redirects remotely - const latestContentRef = await getLatestContentRefForProduct(repoName) + const latestContentSha = await getLatestContentShaForProduct(repoName) redirectsFileString = await fetchGithubFile({ owner: 'hashicorp', repo: repoName, path: redirectsPath, - ref: latestContentRef, + ref: latestContentSha, }) } else if (isLocalContentBuild) { // Load redirects from the filesystem, so that authors can see their changes @@ -106,6 +89,24 @@ async function getRedirectsFromContentRepo( return validRedirects } +/** + * @type {{ repo: string, path: string}[]} An array of redirect + * entries. Each entry specifies a repo and the path within that repo to the + * redirects file. + */ +const PRODUCT_REDIRECT_ENTRIES = [ + { repo: 'boundary', path: 'website/redirects.js' }, + { repo: 'nomad', path: 'website/redirects.js' }, + { repo: 'vault', path: 'website/redirects.js' }, + { repo: 'vagrant', path: 'website/redirects.js' }, + { repo: 'packer', path: 'website/redirects.js' }, + { repo: 'consul', path: 'website/redirects.js' }, + { repo: 'terraform-docs-common', path: 'website/redirects.js' }, + { repo: 'hcp-docs', path: '/redirects.js' }, + { repo: 'ptfe-releases', path: 'website/redirects.js' }, + { repo: 'sentinel', path: 'website/redirects.js' }, +] + async function buildProductRedirects() { // Fetch author-oriented redirects from product repos, // and merge those with dev-oriented redirects from @@ -115,18 +116,11 @@ async function buildProductRedirects() { } const productRedirects = ( - await Promise.all([ - getRedirectsFromContentRepo('boundary'), - getRedirectsFromContentRepo('nomad'), - getRedirectsFromContentRepo('vault'), - getRedirectsFromContentRepo('vagrant'), - getRedirectsFromContentRepo('packer'), - getRedirectsFromContentRepo('consul'), - getRedirectsFromContentRepo('terraform-docs-common'), - getRedirectsFromContentRepo('hcp-docs', '/redirects.js'), - getRedirectsFromContentRepo('ptfe-releases'), - getRedirectsFromContentRepo('sentinel'), - ]) + await Promise.all( + PRODUCT_REDIRECT_ENTRIES.map((entry) => + getRedirectsFromContentRepo(entry.repo, entry.path) + ) + ) ).flat() return productRedirects @@ -407,6 +401,7 @@ async function redirectsConfig() { } module.exports = { + PRODUCT_REDIRECT_ENTRIES, redirectsConfig, splitRedirectsByType, groupSimpleRedirects, diff --git a/src/pages/hcp/api-docs/vagrant-box-registry/[[...page]].tsx b/src/pages/hcp/api-docs/vagrant-box-registry/[[...page]].tsx new file mode 100644 index 0000000000..49ccadbec4 --- /dev/null +++ b/src/pages/hcp/api-docs/vagrant-box-registry/[[...page]].tsx @@ -0,0 +1,118 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// Lib +import { fetchCloudApiVersionData } from 'lib/api-docs/fetch-cloud-api-version-data' +// View +import OpenApiDocsView from 'views/open-api-docs-view' +import { + getStaticPaths, + getStaticProps as getOpenApiDocsStaticProps, +} from 'views/open-api-docs-view/server' +// Types +import type { GetStaticProps, GetStaticPropsContext } from 'next' +import type { OpenAPIV3 } from 'openapi-types' +import type { + OpenApiDocsParams, + OpenApiDocsViewProps, + OpenApiDocsPageConfig, +} from 'views/open-api-docs-view/types' +import { + schemaModShortenHcp, + schemaModComponent, + shortenProtobufAnyDescription, +} from 'views/open-api-docs-view/utils/massage-schema-utils' + +/** + * OpenApiDocsView server-side page configuration + */ +const PAGE_CONFIG: OpenApiDocsPageConfig = { + productSlug: 'hcp', + serviceProductSlug: 'vagrant', + basePath: '/hcp/api-docs/vagrant-box-registry', + githubSourceDirectory: { + owner: 'hashicorp', + repo: 'hcp-specs', + path: 'specs/cloud-vagrant-box-registry', + ref: 'main', + }, + groupOperationsByPath: true, + statusIndicatorConfig: { + pageUrl: 'https://status.hashicorp.com', + endpointUrl: + 'https://status.hashicorp.com/api/v2/components/1mdm36t0fkx1.json', + }, + navResourceItems: [ + { + title: 'Tutorial Library', + href: '/tutorials/library?product=vagrant', + }, + { + title: 'Community', + href: 'https://discuss.hashicorp.com/', + }, + { + title: 'Support', + href: 'https://www.hashicorp.com/customer-success', + }, + ], + + /** + * Massage the schema data a little bit + */ + massageSchemaForClient: (schemaData: OpenAPIV3.Document) => { + // Replace "HashiCorp Cloud Platform" with "HCP" in the title + const withShortTitle = schemaModShortenHcp(schemaData) + /** + * Shorten the description of the protobufAny schema + * + * Note: ideally this would be done at the content source, + * but until we've got that work done, this shortening + * seems necessary to ensure incremental static regeneration works + * for past versions of the API docs. Without this shortening, + * it seems the response size ends up crossing a threshold that + * causes the serverless function that renders the page to fail. + * + * Related task: + * https://app.asana.com/0/1207339219333499/1207339701271604/f + */ + const withShortProtobufDocs = schemaModComponent( + withShortTitle, + 'google.protobuf.Any', + shortenProtobufAnyDescription + ) + // Return the schema data with modifications + return withShortProtobufDocs + }, +} + +/** + * Get static paths, using `versionData` fetched from GitHub. + */ +export { getStaticPaths } + +/** + * Get static props, using `versionData` fetched from GitHub. + * + * We need all version data for the version selector, + * and of course we need specific data for the current version. + */ +export const getStaticProps: GetStaticProps< + OpenApiDocsViewProps, + OpenApiDocsParams +> = async ({ params }: GetStaticPropsContext) => { + // Fetch all version data, based on remote `stable` & `preview` subfolders + const versionData = await fetchCloudApiVersionData( + PAGE_CONFIG.githubSourceDirectory + ) + // Generate static props based on page configuration, params, and versionData + return await getOpenApiDocsStaticProps({ + ...PAGE_CONFIG, + context: { params }, + versionData, + }) +} + +export default OpenApiDocsView diff --git a/src/views/docs-view/loaders/remote-content.ts b/src/views/docs-view/loaders/remote-content.ts index 36b55e43e2..89d9862ba2 100644 --- a/src/views/docs-view/loaders/remote-content.ts +++ b/src/views/docs-view/loaders/remote-content.ts @@ -264,7 +264,22 @@ export default class RemoteContentLoader implements DataLoader { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion versionMetadataList.find((e) => e.version === document.version)! .isLatest - if (isLatest) { + + /** + * We want to show "Edit on GitHub" links for public content repos only. + * Currently, HCP, PTFE and Sentinel docs are stored in private + * repositories. + * + * Note: If we need more granularity here, we could change this to be + * part of `rootDocsPath` configuration in `src/data/.json`. + */ + const isPrivateContentRepo = [ + 'hcp-docs', + 'sentinel', + 'ptfe-releases', + ].includes(this.opts.product) + + if (isLatest && !isPrivateContentRepo) { // GitHub only allows you to modify a file if you are on a branch, not a commit githubFileUrl = `https://github.com/hashicorp/${this.opts.product}/blob/${this.opts.mainBranch}/${document.githubFile}` } diff --git a/src/views/docs-view/server.ts b/src/views/docs-view/server.ts index b60f05a9f2..18fe0184c7 100644 --- a/src/views/docs-view/server.ts +++ b/src/views/docs-view/server.ts @@ -410,19 +410,7 @@ export function getStaticGenerationFunctions< validVersions.length > 0 && (validVersions.length > 1 || validVersions[0].version !== 'v0.0.x') - /** - * We want to show "Edit on GitHub" links for public content repos only. - * Currently, HCP and Sentinel docs are stored in private repositories. - * - * Note: If we need more granularity here, we could change this to be - * part of `rootDocsPath` configuration in `src/data/.json`. - */ - const isHcp = product.slug == 'hcp' - const isSentinel = product.slug == 'sentinel' - const isPublicContentRepo = !isHcp && !isSentinel - if (isPublicContentRepo) { - layoutProps.githubFileUrl = githubFileUrl - } + layoutProps.githubFileUrl = githubFileUrl const { hideVersionSelector, projectName } = options