diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 874caf3d..14d0df5b 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1091,3 +1091,252 @@ test("It identifies the default branch in returned versions", async () => { .map(e => e.name) expect(defaultVersionNames).toEqual(["development"]) }) + +test("It adds remote versions from the project configuration", async () => { + const rawProjectConfig = ` + remoteVersions: + - name: Anne + specifications: + - name: Huey + url: https://example.com/huey.yml + - name: Dewey + url: https://example.com/dewey.yml + - name: Bobby + specifications: + - name: Louie + url: https://example.com/louie.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "main", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "anne", + name: "Anne", + isDefault: false, + specifications: [{ + id: "huey", + name: "Huey", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/huey.yml")}` + }, { + id: "dewey", + name: "Dewey", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/dewey.yml")}` + }] + }, { + id: "bobby", + name: "Bobby", + isDefault: false, + specifications: [{ + id: "louie", + name: "Louie", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/louie.yml")}` + }] + }]) +}) + +test("It modifies ID of remote version if the ID already exists", async () => { + const rawProjectConfig = ` + remoteVersions: + - name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + - name: Bar + specifications: + - name: Hello + url: https://example.com/hello.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "bar", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [{ + node: { + name: "bar", + target: { + oid: "12345678", + tree: { + entries: [{ + name: "openapi.yml" + }] + } + } + } + }] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "bar", + name: "bar", + url: "https://github.com/acme/foo/tree/bar", + isDefault: true, + specifications: [{ + id: "openapi.yml", + name: "openapi.yml", + url: "/api/blob/acme/foo/openapi.yml?ref=12345678", + editURL: "https://github.com/acme/foo/edit/bar/openapi.yml" + }] + }, { + id: "bar1", + name: "Bar", + isDefault: false, + specifications: [{ + id: "baz", + name: "Baz", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + }] + }, { + id: "bar2", + name: "Bar", + isDefault: false, + specifications: [{ + id: "hello", + name: "Hello", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/hello.yml")}` + }] + }]) +}) + +test("It lets users specify the ID of a remote version", async () => { + const rawProjectConfig = ` + remoteVersions: + - id: some-version + name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "bar", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "some-version", + name: "Bar", + isDefault: false, + specifications: [{ + id: "baz", + name: "Baz", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + }] + }]) +}) + +test("It lets users specify the ID of a remote specification", async () => { + const rawProjectConfig = ` + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + ` + const sut = new GitHubProjectDataSource({ + dataSource: { + async getRepositories() { + return [{ + name: "foo", + owner: { + login: "acme" + }, + defaultBranchRef: { + name: "bar", + target: { + oid: "12345678" + } + }, + configYml: { + text: rawProjectConfig + }, + branches: { + edges: [] + }, + tags: { + edges: [] + } + }] + } + } + }) + const projects = await sut.getProjects() + expect(projects[0].versions).toEqual([{ + id: "bar", + name: "Bar", + isDefault: false, + specifications: [{ + id: "some-spec", + name: "Baz", + url: `/api/proxy?url=${encodeURIComponent("https://example.com/baz.yml")}` + }] + }]) +}) diff --git a/__test__/projects/getSelection.test.ts b/__test__/projects/getSelection.test.ts index f2abf99f..a3979b44 100644 --- a/__test__/projects/getSelection.test.ts +++ b/__test__/projects/getSelection.test.ts @@ -230,3 +230,27 @@ test("It returns a undefined specification when the selected specification canno expect(sut.version!.id).toEqual("bar") expect(sut.specification).toBeUndefined() }) + +test("It moves specification ID to version ID if needed", () => { + const sut = getSelection({ + path: "/foo/bar/baz", + projects: [{ + id: "foo", + name: "foo", + displayName: "foo", + versions: [{ + id: "bar/baz", + name: "bar", + isDefault: false, + specifications: [{ + id: "hello", + name: "hello.yml", + url: "https://example.com/hello.yml" + }] + }] + }] + }) + expect(sut.project!.id).toEqual("foo") + expect(sut.version!.id).toEqual("bar/baz") + expect(sut.specification!.id).toEqual("hello") +}) diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts new file mode 100644 index 00000000..7245898c --- /dev/null +++ b/src/app/api/proxy/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server" +import { makeAPIErrorResponse } from "@/common" + +export async function GET(req: NextRequest) { + const rawURL = req.nextUrl.searchParams.get("url") + if (!rawURL) { + return makeAPIErrorResponse(400, "Missing \"url\" query parameter.") + } + let url: URL + try { + url = new URL(rawURL) + } catch { + return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") + } + const file = await fetch(url).then(r => r.blob()) + return new NextResponse(file, { status: 200 }) +} diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index bdf126c4..f94d58f6 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -3,10 +3,11 @@ import GitHubProjectRepository, { } from "./GitHubProjectRepository" import { Project, + Version, IProjectConfig, IProjectDataSource, - Version, - ProjectConfigParser + ProjectConfigParser, + ProjectConfigRemoteVersion, } from "../domain" interface IGitHubProjectRepositoryDataSource { @@ -48,14 +49,21 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ref: repository.defaultBranchRef.target.oid }) } + const versions = this.sortVersions( + this.addRemoteVersions( + this.getVersions(repository), + config?.remoteVersions || [] + ), + repository.defaultBranchRef.name + ).filter(version => { + return version.specifications.length > 0 + }) const defaultName = repository.name.replace(/-openapi$/, "") return { id: defaultName, name: defaultName, displayName: config?.name || defaultName, - versions: this.getVersions(repository).filter(version => { - return version.specifications.length > 0 - }), + versions, imageURL: imageURL } } @@ -86,27 +94,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { ref: edge.node }) }) - const defaultBranchName = repository.defaultBranchRef.name - const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development" - ] - // Reverse them so the top-priority branches end up at the top of the list. - .reverse() - const allVersions = branchVersions.concat(tagVersions).sort((a: Version, b: Version) => { - return a.name.localeCompare(b.name) - }) - // Move the top-priority branches to the top of the list. - for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = allVersions.findIndex((version: Version) => { - return version.name === candidateDefaultBranch - }) - if (defaultBranchIndex !== -1) { - const branchVersion = allVersions[defaultBranchIndex] - allVersions.splice(defaultBranchIndex, 1) - allVersions.splice(0, 0, branchVersion) - } - } - return allVersions + return branchVersions.concat(tagVersions) } private mapVersionFromRef({ @@ -163,4 +151,65 @@ export default class GitHubProjectDataSource implements IProjectDataSource { }): string { return `/api/blob/${ownerName}/${repositoryName}/${path}?ref=${ref}` } + + private addRemoteVersions( + existingVersions: Version[], + remoteVersions: ProjectConfigRemoteVersion[] + ): Version[] { + const versions = [...existingVersions] + const versionIds = versions.map(e => e.id) + for (const remoteVersion of remoteVersions) { + const baseVersionId = this.makeURLSafeID( + (remoteVersion.id || remoteVersion.name).toLowerCase() + ) + // If the version ID exists then we suffix it with a number to ensure unique versions. + // E.g. if "foo" already exists, we make it "foo1". + const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length + const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") + const specifications = remoteVersion.specifications.map(e => { + return { + id: this.makeURLSafeID((e.id || e.name).toLowerCase()), + name: e.name, + url: `/api/proxy?url=${encodeURIComponent(e.url)}` + } + }) + versions.push({ + id: versionId, + name: remoteVersion.name, + specifications, + isDefault: false + }) + versionIds.push(baseVersionId) + } + return versions + } + + private sortVersions(versions: Version[], defaultBranchName: string): Version[] { + const candidateDefaultBranches = [ + defaultBranchName, "main", "master", "develop", "development", "trunk" + ] + // Reverse them so the top-priority branches end up at the top of the list. + .reverse() + const copiedVersions = [...versions].sort((a, b) => { + return a.name.localeCompare(b.name) + }) + // Move the top-priority branches to the top of the list. + for (const candidateDefaultBranch of candidateDefaultBranches) { + const defaultBranchIndex = copiedVersions.findIndex(version => { + return version.name === candidateDefaultBranch + }) + if (defaultBranchIndex !== -1) { + const branchVersion = copiedVersions[defaultBranchIndex] + copiedVersions.splice(defaultBranchIndex, 1) + copiedVersions.splice(0, 0, branchVersion) + } + } + return copiedVersions + } + + private makeURLSafeID(str: string): string { + return str + .replace(/ /g, "-") + .replace(/[^A-Za-z0-9-]/g, "") + } } diff --git a/src/features/projects/domain/IProjectConfig.ts b/src/features/projects/domain/IProjectConfig.ts index 3dc32a0e..7f047f19 100644 --- a/src/features/projects/domain/IProjectConfig.ts +++ b/src/features/projects/domain/IProjectConfig.ts @@ -1,4 +1,17 @@ +export type ProjectConfigRemoteVersion = { + readonly id?: string + readonly name: string + readonly specifications: ProjectConfigRemoteSpecification[] +} + +export type ProjectConfigRemoteSpecification = { + readonly id?: string + readonly name: string + readonly url: string +} + export default interface IProjectConfig { readonly name?: string readonly image?: string + readonly remoteVersions?: ProjectConfigRemoteVersion[] } diff --git a/src/features/projects/domain/getSelection.ts b/src/features/projects/domain/getSelection.ts index 307f6b7c..b76559ad 100644 --- a/src/features/projects/domain/getSelection.ts +++ b/src/features/projects/domain/getSelection.ts @@ -30,8 +30,24 @@ export default function getSelection({ return {} } let version: Version | undefined + let didMoveSpecificationIdToVersionId = false if (versionId) { version = project.versions.find(e => e.id == versionId) + if (!version && specificationId && !isSpecificationIdFilename(specificationId)) { + // With the introduction of remote versions that are specified in the .shape-docs.yml + // configuration file, it has become impossible to tell if the last component in a URL + // is the specification ID or if it belongs to the version ID. Previously, we required + // specification IDs to end with either ".yml" or ".yaml" but that no longer makes + // sense when users can define versions. + // Instead we assume that the last component is the specification ID but if we cannot + // find a version with what we then believe to be the version ID, then we attempt to + // finding a version with the ID `{versionId}/{specificationId}` and if that succeeds, + // we select the first specification in that version by flagging that the ID of the + // specification is considered part of the version ID. + const longId = [versionId, specificationId].join("/") + version = project.versions.find(e => e.id == longId) + didMoveSpecificationIdToVersionId = version != undefined + } } else if (project.versions.length > 0) { version = project.versions[0] } @@ -39,7 +55,7 @@ export default function getSelection({ return { project } } let specification: OpenApiSpecification | undefined - if (specificationId) { + if (specificationId && !didMoveSpecificationIdToVersionId) { specification = version.specifications.find(e => e.id == specificationId) } else if (version.specifications.length > 0) { specification = version.specifications[0] @@ -65,3 +81,7 @@ function guessSelection(pathname: string) { return { projectId, versionId, specificationId } } } + +function isSpecificationIdFilename(specificationId: string): boolean { + return specificationId.endsWith(".yml") || specificationId.endsWith(".yaml") +} diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index 240996ef..bfce5ee2 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,6 +1,7 @@ export { default as CachingProjectDataSource } from "./CachingProjectDataSource" export { default as getSelection } from "./getSelection" export type { default as IProjectConfig } from "./IProjectConfig" +export * from "./IProjectConfig" export type { default as IProjectDataSource } from "./IProjectDataSource" export type { default as OpenApiSpecification } from "./OpenApiSpecification" export type { default as Project } from "./Project"