diff --git a/docs/apis.json b/docs/apis.json
index fadca50..fbb1888 100644
--- a/docs/apis.json
+++ b/docs/apis.json
@@ -66,8 +66,8 @@
],
"contact": [
{
- "X-contact": "https://emailus.usps.com/",
- "phone": "1-800-344-7779"
+ "Url": "https://emailus.usps.com/",
+ "Tel": "1-800-344-7779"
}
]
},
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/.gitignore b/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/.gitignore
new file mode 100644
index 0000000..d90d750
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/.gitignore
@@ -0,0 +1,17 @@
+[Bb]in/
+[Oo]bj/
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+build/
+bld/
+msbuild.log
+msbuild.err
+msbuild.wrn
+
+*.suo
+*.user
+*.ncb
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/Gov.Apis.SubmissionEndpoint.Tests.csproj b/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/Gov.Apis.SubmissionEndpoint.Tests.csproj
new file mode 100644
index 0000000..bff0a20
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/Gov.Apis.SubmissionEndpoint.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ netcoreapp3.1
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/Services/GitHubSubmissionServiceTests.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/Services/GitHubSubmissionServiceTests.cs
new file mode 100644
index 0000000..22c4771
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint.Tests/Services/GitHubSubmissionServiceTests.cs
@@ -0,0 +1,399 @@
+using Gov.Apis.SubmissionEndpoint.Models.ApisDotJson;
+using Gov.Apis.SubmissionEndpoint.Services;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Caching.Memory;
+using Moq;
+using Octokit;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Xunit;
+
+#nullable disable
+namespace Gov.Apis.SubmissionEndpoint.Tests.Services
+{
+ public class GitHubSubmissionServiceTests
+ {
+ private const string OWNER = "owner";
+ private const string REPO = "repo";
+ private const string DEFAULT_BRANCH = "defaultBranch";
+ private const string MANIFEST_PATH = "path/to/manifest.json";
+ private const string ID = "id";
+ private const string BASE_COMMIT_ID = "baseCommitId";
+ private const string BASE_TREE_ID = "baseTreeId";
+ private const string BLOB_ID = "blobId";
+ private const string NEW_TREE_ID = "newTreeId";
+ private const string NEW_COMMIT_ID = "newCommitId";
+ private const string PULL_REQUEST_URL = "pullRequestUrl";
+
+ [Fact]
+ public async Task Submit_ShouldCreateTheRequisiteResourcesAndOpenAPullRequest()
+ {
+ var clientMock = new Mock();
+
+ // stub out the workflow of a successful submission
+ clientMock.StubGetRepository(OWNER, REPO, DEFAULT_BRANCH);
+ clientMock.StubGetBranch(OWNER, REPO, DEFAULT_BRANCH, BASE_COMMIT_ID);
+ clientMock.StubGetCommit(OWNER, REPO, BASE_COMMIT_ID, BASE_TREE_ID);
+ clientMock.StubGetFileAtRef(OWNER, REPO, BASE_COMMIT_ID, MANIFEST_PATH, new byte[] { 0x7b, 0x7d });
+ clientMock.StubCreateBlob(OWNER, REPO, BLOB_ID);
+ clientMock.StubCreateTree(OWNER, REPO, BASE_TREE_ID, MANIFEST_PATH, BLOB_ID, NEW_TREE_ID);
+ clientMock.StubCreateCommit(OWNER, REPO, NEW_TREE_ID, BASE_COMMIT_ID, NEW_COMMIT_ID);
+ clientMock.StubCreateBranch(OWNER, REPO, NEW_COMMIT_ID, $"submission/{ID}");
+ clientMock.StubCreatePullRequest(OWNER, REPO, $"submission/{ID}", DEFAULT_BRANCH, PULL_REQUEST_URL);
+
+ var sut = new GitHubSubmissionService(
+ new Mock>().Object,
+ clientMock.Object,
+ new MemoryCache(new MemoryCacheOptions()),
+ OWNER,
+ REPO,
+ MANIFEST_PATH,
+ api => ID
+ );
+
+ var response = await sut.Submit(new Api());
+ Assert.Equal(ID, response.Id);
+ Assert.Equal(PULL_REQUEST_URL, response.PublicUrl);
+ }
+
+ [Fact]
+ public async Task Submit_ShouldDeleteSubmissionBranchWhenPullRequestCreationFails()
+ {
+
+ var clientMock = new Mock();
+
+ // stub out most of the workflow of a successful submission
+ clientMock.StubGetRepository(OWNER, REPO, DEFAULT_BRANCH);
+ clientMock.StubGetBranch(OWNER, REPO, DEFAULT_BRANCH, BASE_COMMIT_ID);
+ clientMock.StubGetCommit(OWNER, REPO, BASE_COMMIT_ID, BASE_TREE_ID);
+ clientMock.StubGetFileAtRef(OWNER, REPO, BASE_COMMIT_ID, MANIFEST_PATH, new byte[] { 0x7b, 0x7d });
+ clientMock.StubCreateBlob(OWNER, REPO, BLOB_ID);
+ clientMock.StubCreateTree(OWNER, REPO, BASE_TREE_ID, MANIFEST_PATH, BLOB_ID, NEW_TREE_ID);
+ clientMock.StubCreateCommit(OWNER, REPO, NEW_TREE_ID, BASE_COMMIT_ID, NEW_COMMIT_ID);
+ clientMock.StubCreateBranch(OWNER, REPO, NEW_COMMIT_ID, $"submission/{ID}");
+
+ // then have it fail on the last step
+ clientMock.StubPullRequestCreationFailure(OWNER, REPO, $"submission/{ID}", DEFAULT_BRANCH, new InvalidProgramException());
+
+ var sut = new GitHubSubmissionService(
+ new Mock>().Object,
+ clientMock.Object,
+ new MemoryCache(new MemoryCacheOptions()),
+ OWNER,
+ REPO,
+ MANIFEST_PATH,
+ api => ID
+ );
+
+ // The original exception should be surfaced...
+ await Assert.ThrowsAsync(() => sut.Submit(new Api()));
+
+ // and the branch should be deleted.
+ clientMock.Verify(_ => _.Git.Reference.Delete(OWNER, REPO, $"refs/heads/submission/{ID}"));
+ }
+ }
+
+ public static class ExtensionMethods
+ {
+ public static void StubGetRepository(
+ this Mock mock,
+ string owner,
+ string repo,
+ string defaultBranch
+ ) {
+ var repository = new Repository(defaultBranch: defaultBranch,
+ url: null,
+ htmlUrl: null,
+ cloneUrl: null,
+ gitUrl: null,
+ sshUrl: null,
+ svnUrl: null,
+ mirrorUrl: null,
+ id: 0,
+ nodeId: null,
+ owner: null,
+ name: null,
+ fullName: null,
+ isTemplate: false,
+ description: null,
+ homepage: null,
+ language: null,
+ @private: false,
+ fork: false,
+ forksCount: 0,
+ stargazersCount: 0,
+ openIssuesCount: 0,
+ pushedAt: null,
+ createdAt: DateTimeOffset.Now,
+ updatedAt: DateTimeOffset.Now,
+ permissions: null,
+ parent: null,
+ source: null,
+ license: null,
+ hasIssues: false,
+ hasWiki: false,
+ hasDownloads: false,
+ hasPages: false,
+ subscribersCount: 0,
+ size: 0,
+ allowRebaseMerge: null,
+ allowSquashMerge: null,
+ allowMergeCommit: null,
+ archived: false);
+ mock
+ .Setup(_ => _.Repository.Get(owner, repo))
+ .ReturnsAsync(repository);
+ }
+
+ public static void StubGetBranch(
+ this Mock mock,
+ string owner,
+ string repo,
+ string branchName,
+ string baseCommitId
+ ) {
+ mock
+ .Setup(_ => _.Repository.Branch.Get(owner, repo, branchName))
+ .ReturnsAsync(new Branch(branchName, RefAtSha(baseCommitId), false));
+ }
+
+ public static void StubGetCommit(
+ this Mock mock,
+ string owner,
+ string repo,
+ string baseCommitId,
+ string baseTreeId
+ ) {
+ var commit = new Commit(sha: baseCommitId,
+ tree: RefAtSha(baseTreeId),
+ nodeId: null,
+ url: null,
+ label: null,
+ @ref: null,
+ user: null,
+ repository: null,
+ message: null,
+ author: null,
+ committer: null,
+ parents: new [] { RefAtSha(baseCommitId) },
+ commentCount: 0,
+ verification: null);
+
+ var ghCommit = new GitHubCommit(sha: baseCommitId,
+ commit: commit,
+ nodeId: null,
+ url: null,
+ label: null,
+ @ref: null,
+ user: null,
+ repository: null,
+ author: null,
+ commentsUrl: null,
+ committer: null,
+ htmlUrl: null,
+ stats: null,
+ parents: null,
+ files: null);
+
+ mock
+ .Setup(_ => _.Repository.Commit.Get(owner, repo, baseCommitId))
+ .ReturnsAsync(ghCommit);
+ }
+
+ public static void StubGetFileAtRef(
+ this Mock mock,
+ string owner,
+ string repo,
+ string commitSha,
+ string filePath,
+ byte[] content
+ ) {
+ var repoContent = new RepositoryContent(
+ name: null,
+ path: filePath,
+ sha: null,
+ size: 0,
+ type: ContentType.File,
+ downloadUrl: null,
+ url: null,
+ gitUrl: null,
+ htmlUrl: null,
+ encoding: "base64",
+ encodedContent: System.Convert.ToBase64String(content),
+ target: null,
+ submoduleGitUrl: null);
+
+ mock
+ .Setup(_ => _.Repository.Content.GetAllContentsByRef(owner, repo, filePath, commitSha))
+ .ReturnsAsync(new [] { repoContent });
+ }
+
+ public static void StubCreateBlob(
+ this Mock mock,
+ string owner,
+ string repo,
+ string blobId
+ ) {
+ mock
+ .Setup(_ => _.Git.Blob.Create(owner,
+ repo,
+ It.IsAny()))
+ .ReturnsAsync(new BlobReference(blobId));
+ }
+
+ public static void StubCreateTree(
+ this Mock mock,
+ string owner,
+ string repo,
+ string baseTreeId,
+ string filePath,
+ string blobId,
+ string newTreeId
+ ) {
+ var treeReponse = new TreeResponse(sha: newTreeId,
+ url: null,
+ tree: null,
+ truncated: false);
+
+ mock
+ .Setup(_ => _.Git.Tree.Create(owner,
+ repo,
+ It.Is(t => t.BaseTree == baseTreeId
+ && t.Tree.Count == 1
+ && t.Tree.First().Sha == blobId)))
+ .ReturnsAsync(treeReponse);
+ }
+
+ public static void StubCreateCommit(
+ this Mock mock,
+ string owner,
+ string repo,
+ string treeId,
+ string parentId,
+ string commitId
+ ) {
+ var commit = new Commit(sha: commitId,
+ nodeId: null,
+ url: null,
+ label: null,
+ @ref: null,
+ user: null,
+ repository: null,
+ message: null,
+ author: null,
+ committer: null,
+ tree: null,
+ parents: new [] { RefAtSha(parentId) },
+ commentCount: 0,
+ verification: null);
+
+ mock
+ .Setup(_ => _.Git.Commit.Create(owner,
+ repo,
+ It.Is(c => c.Tree == treeId
+ && c.Parents.Count() == 1
+ && c.Parents.First() == parentId)))
+ .ReturnsAsync(commit);
+ }
+
+ public static void StubCreateBranch(
+ this Mock mock,
+ string owner,
+ string repo,
+ string commitId,
+ string branchName
+ ) {
+ var reference = new Reference(@ref: null,
+ nodeId: null,
+ url: null,
+ @object: null);
+
+ mock
+ .Setup(_ => _.Git.Reference.Create(owner,
+ repo,
+ It.Is(r => r.Sha == commitId
+ && r.Ref == $"refs/heads/{branchName}")))
+ .ReturnsAsync(reference);
+ }
+
+ public static void StubCreatePullRequest(
+ this Mock mock,
+ string owner,
+ string repo,
+ string headBranchName,
+ string baseBranchName,
+ string url
+ ) {
+ var pullRequest = new PullRequest(url: url,
+ id: 0,
+ nodeId: null,
+ htmlUrl: null,
+ diffUrl: null,
+ patchUrl: null,
+ issueUrl: null,
+ statusesUrl: null,
+ number: 0,
+ state: new ItemState(),
+ title: null,
+ body: null,
+ createdAt: DateTimeOffset.Now,
+ updatedAt: DateTimeOffset.Now,
+ closedAt: null,
+ mergedAt: null,
+ head: null,
+ @base: null,
+ user: null,
+ assignee: null,
+ assignees: null,
+ draft: false,
+ mergeable: null,
+ mergeableState: null,
+ mergedBy: null,
+ mergeCommitSha: null,
+ comments: 0,
+ commits: 0,
+ additions: 0,
+ deletions: 0,
+ changedFiles: 0,
+ milestone: null,
+ locked: false,
+ maintainerCanModify: null,
+ requestedReviewers: null,
+ labels: null);
+
+ mock
+ .Setup(_ => _.Repository.PullRequest.Create(owner,
+ repo,
+ It.Is(pr => pr.Head == headBranchName && pr.Base == baseBranchName)))
+ .ReturnsAsync(pullRequest);
+ }
+
+ public static void StubPullRequestCreationFailure(
+ this Mock mock,
+ string owner,
+ string repo,
+ string headBranchName,
+ string baseBranchName,
+ Exception toThrow
+ ) {
+ mock
+ .Setup(_ => _.Repository.PullRequest.Create(owner,
+ repo,
+ It.Is(pr => pr.Head == headBranchName && pr.Base == baseBranchName)))
+ .ThrowsAsync(toThrow);
+ }
+
+ private static GitReference RefAtSha(string sha)
+ {
+ return new GitReference(sha: sha,
+ nodeId: null,
+ url: null,
+ label: null,
+ @ref: null,
+ user: null,
+ repository: null);
+ }
+ }
+}
+#nullable restore
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/.gitignore b/submission-endpoint/Gov.Apis.SubmissionEndpoint/.gitignore
new file mode 100644
index 0000000..d90d750
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/.gitignore
@@ -0,0 +1,17 @@
+[Bb]in/
+[Oo]bj/
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+build/
+bld/
+msbuild.log
+msbuild.err
+msbuild.wrn
+
+*.suo
+*.user
+*.ncb
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Controllers/SubmissionController.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Controllers/SubmissionController.cs
new file mode 100755
index 0000000..8ad41d2
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Controllers/SubmissionController.cs
@@ -0,0 +1,38 @@
+using Gov.Apis.SubmissionEndpoint.Models;
+using Gov.Apis.SubmissionEndpoint.Models.ApisDotJson;
+using Gov.Apis.SubmissionEndpoint.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+
+namespace Gov.Apis.SubmissionEndpoint.Controllers
+{
+ [Route("submissions")]
+ [ApiController]
+ public class SubmissionController : ControllerBase
+ {
+ private readonly ILogger _logger;
+ private readonly ISubmissionService submissionService;
+
+ public SubmissionController(
+ ILogger logger,
+ ISubmissionService submissionService
+ ) {
+ _logger = logger;
+ this.submissionService = submissionService;
+ }
+
+ [Produces("application/json")]
+ [Consumes("application/json")]
+ [RequestSizeLimit(2 * 1024 * 1024)]
+ public IActionResult Post(Api api)
+ {
+ _logger.LogInformation("Received external API submission for [{Name}]", api.Name);
+
+ Task task = submissionService.Submit(api);
+
+ task.Wait();
+ return Ok(task.Result);
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Gov.Apis.SubmissionEndpoint.csproj b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Gov.Apis.SubmissionEndpoint.csproj
new file mode 100644
index 0000000..4fe94e0
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Gov.Apis.SubmissionEndpoint.csproj
@@ -0,0 +1,13 @@
+
+
+
+ netcoreapp3.1
+ enable
+
+
+
+
+
+
+
+
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Api.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Api.cs
new file mode 100755
index 0000000..22adb51
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Api.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+
+namespace Gov.Apis.SubmissionEndpoint.Models.ApisDotJson
+{
+ /// Metadata describing an individual API.
+ public class Api
+ {
+ /// Name of the API.
+ [Required]
+ public string? Name { get; set; }
+
+ /// Human readable description of the API.
+ [Required]
+ public string? Description { get; set; }
+
+ /// URL of an image which can be used as an icon for the API if displayed by a search engine.
+ [Url]
+ [JsonPropertyName("Image")]
+ public string? ImageUrl { get; set; }
+
+ /// Web URL corresponding to human readable information about the API.
+ [Url]
+ [JsonPropertyName("humanUrl")]
+ public string? HumanUrl { get; set; }
+
+ /// Web URL corresponding to the root URL of the API or primary endpoint.
+ [Required]
+ [Url]
+ [JsonPropertyName("baseUrl")]
+ public string? BaseUrl { get; set; }
+
+ /// String representing the version number of the API this description refers to.
+ public string? Version { get; set; }
+
+ /// A list of descriptive strings which identify the API.
+ public IEnumerable? Tags { get; set; }
+
+ [JsonPropertyName("properties")]
+ public IEnumerable? Properties { get; set; }
+
+ [JsonPropertyName("contact")]
+ public IEnumerable? Contacts { get; set; }
+
+ public class Property
+ {
+ [Required]
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [Required]
+ [JsonPropertyName("url")]
+ public string? Url { get; set; }
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Contact.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Contact.cs
new file mode 100755
index 0000000..b9123e2
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Contact.cs
@@ -0,0 +1,55 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+
+namespace Gov.Apis.SubmissionEndpoint.Models.ApisDotJson
+{
+ /// A contact record for a person or organization.
+ /// Values are all taken from the vCard specification.
+ public class Contact
+ {
+ /// String Value corresponding to the Full Nname name of the individual / organization.
+ [JsonPropertyName("FN")]
+ public string? FullName { get; set; }
+
+ /// String Value corresponding to the email address of the individual / organization
+ public string? Email { get; set; }
+
+ /// String Value corresponding to a web page about the individual individual / organization
+ [Url]
+ public string? Url { get; set; }
+
+ /// String Value representing the name of the organization associated with the cCard.
+ [JsonPropertyName("org")]
+ public string? Organization { get; set; }
+
+ /// String Value corresponding to the physical address of the individual / organization.
+ [JsonPropertyName("Adr")]
+ public string? Address { get; set; }
+
+ /// String Value corresponding to the phone number including country code of the
+ /// individual / organization.
+ [JsonPropertyName("Tel")]
+ public string? TelephoneNumber { get; set; }
+
+ /// String Value corresponding to the twitter username of the individual / organization.
+ /// convention do not use the "@" symbol
+ [JsonPropertyName("X-Twitter")]
+ public string? TwitterHandler { get; set; }
+
+ /// String Value corresponding to the github username of the individual / organization.
+ [JsonPropertyName("X-Github")]
+ public string? GitHubUserName { get; set; }
+
+ /// URL corresponding to an image which could be used to represent the
+ /// individual / organization.
+ [Url]
+ [JsonPropertyName("photo")]
+ public string? PhotoUrl { get; set; }
+
+ /// URL pointing to a vCard Objective RFC6350
+ [Url]
+ [JsonPropertyName("vCard")]
+ public string? VCardUrl { get; set; }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Manifest.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Manifest.cs
new file mode 100644
index 0000000..5823844
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/ApisDotJson/Manifest.cs
@@ -0,0 +1,55 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Gov.Apis.SubmissionEndpoint.Models.ApisDotJson
+{
+ /// A single inventory of all API resources available within a domain
+ public class Manifest
+ {
+ private static readonly string SPECIFICATION_VERSION = "0.15";
+
+ /// text string of human readable name for the collection of APIs
+ public string? Name { get; set; }
+
+ /// text human readable description of the collection of APIs.
+ public string? Description { get; set; }
+
+ /// Web URL leading to an image to be used to represent the collection of
+ /// APIs defined in this file.
+ public string? Image { get; set; }
+
+ /// Web URL indicating the location of the latest version of this file
+ public string? Url { get; set; }
+
+ /// a list of descriptive strings which identify the contents of the APIs.json file
+ public IEnumerable? Tags { get; set; }
+
+ /// date of creation of the file
+ public string? Created { get; set; }
+
+ /// date of last modification of the file
+ public string? Modified { get; set; }
+
+ /// version of the APIs.json specification in use.
+ [JsonPropertyName("specificationVersion")]
+ public string SpecificationVersion { get { return SPECIFICATION_VERSION; } }
+
+ /// list of APIs identified in the file
+ public IEnumerable? Apis { get; set; }
+
+ ///
+ public IEnumerable? Include { get; set; }
+
+ ///
+ public IEnumerable? Maintainers { get; set; }
+
+ public class Inclusion
+ {
+ /// name of the APIs.json file referenced.
+ public string? Name { get; set; }
+
+ /// Web URL of the file.
+ public string? Url { get; set; }
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/SubmissionResponse.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/SubmissionResponse.cs
new file mode 100755
index 0000000..05018cb
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Models/SubmissionResponse.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Gov.Apis.SubmissionEndpoint.Models
+{
+ public class SubmissionResponse
+ {
+ public string Id { get; }
+ public string? PublicUrl { get; set; }
+ public string? InternalUrl { get; set; }
+
+ public SubmissionResponse(string id)
+ {
+ Id = id;
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Program.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Program.cs
new file mode 100644
index 0000000..943c63e
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Program.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Gov.Apis.SubmissionEndpoint
+{
+ public class Program
+ {
+ public static void Main(string[] args)
+ {
+ CreateHostBuilder(args).Build().Run();
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder =>
+ {
+ webBuilder.UseStartup();
+ });
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Properties/launchSettings.json b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Properties/launchSettings.json
new file mode 100644
index 0000000..75a4491
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Properties/launchSettings.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:58283",
+ "sslPort": 44326
+ }
+ },
+ "profiles": {
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "weatherforecast",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "Gov.Apis.SubmissionEndpoint": {
+ "commandName": "Project",
+ "applicationUrl": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Services/GitHubSubmissionService.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Services/GitHubSubmissionService.cs
new file mode 100644
index 0000000..f1492fd
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Services/GitHubSubmissionService.cs
@@ -0,0 +1,276 @@
+using Gov.Apis.SubmissionEndpoint.Models;
+using Gov.Apis.SubmissionEndpoint.Models.ApisDotJson;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Caching.Memory;
+using Octokit;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace Gov.Apis.SubmissionEndpoint.Services
+{
+ public class GitHubSubmissionService : ISubmissionService
+ {
+ private readonly ILogger logger;
+ private readonly IGitHubClient gitHubClient;
+ private readonly IMemoryCache cache;
+ private readonly string repositoryOwner;
+ private readonly string repositoryName;
+ private readonly string apisDotJsonPath;
+ private readonly Func idProvider;
+
+ public GitHubSubmissionService(
+ ILogger logger,
+ IGitHubClient gitHubClient,
+ IMemoryCache cache,
+ string repositoryOwner,
+ string repositoryName,
+ string apisDotJsonPath,
+ [Optional] Func idProvider
+ )
+ {
+ this.logger = logger;
+ this.gitHubClient = gitHubClient;
+ this.cache = cache;
+ this.repositoryOwner = repositoryOwner;
+ this.repositoryName = repositoryName;
+ this.apisDotJsonPath = apisDotJsonPath;
+ this.idProvider = idProvider ?? GitHubSubmissionService.defaultIdProvider;
+ }
+
+ public async Task Submit(Api api)
+ {
+ var id = idProvider.Invoke(api);
+ // Attach submission metadata to the logging scope for the duration of this method
+ using (logger.BeginScope(new Dictionary() {
+ ["ApiName"] = api.Name!,
+ ["ApiBaseUrl"] = api.BaseUrl!,
+ ["WorkflowId"] = id,
+ })) {
+ var state = await PrepareBaseState(id);
+
+ try {
+ var manifest = await FetchManifestAtRef(reference: state.BaseCommitId);
+
+ // Append the submitted API to the end of the Apis list
+ manifest.Apis = Enumerable.Concat(manifest.Apis ?? new Api[0], new Api[] { api });
+ manifest.Modified = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd");
+
+ state.BlobId = await CreateBlobFromManifest(manifest: manifest);
+
+ state.TreeId = await CreateTreeFromManifestBlob(baseTreeId: state.BaseTreeId,
+ blobId: state.BlobId!);
+
+ state.CommitId = await CreateCommitFromTree(treeId: state.TreeId!,
+ commitMessage: $"Add API record for {api.Name!} to docs/apis.json",
+ baseCommitId: state.BaseCommitId);
+
+ state.BranchName = await CreateBranchFromCommit(branchName: $"submission/{id}",
+ commitId: state.CommitId!);
+
+ var pullRequestUrl = await CreatePullRequest(baseBranchName: state.BaseBranchName,
+ title: $"Add API record for {api.Name!} to docs/apis.json",
+ body: $"A website user submitted this metadata about {api.Name!} and would like it featured on apis.gov",
+ headBranchName: state.BranchName!);
+
+ logger.LogInformation("Pull request successfully submitted.");
+
+ return new SubmissionResponse(id) { PublicUrl = pullRequestUrl };
+ } catch (Exception e) {
+ logger.LogError(e, "Error encountered during; attempting to roll back");
+ try {
+ await Rollback(id, state);
+ } catch (Exception err) {
+ logger.LogWarning(err, "Unable to rollback workflow. Manual cleanup may be required.");
+ }
+ throw e;
+ }
+ }
+ }
+
+ private async Task PrepareBaseState(string id)
+ {
+ var defaultBranchName = cache.GetOrCreate>($"{repositoryOwner}/{repositoryName}:defaultBranch",
+ entry => {
+ entry.SetAbsoluteExpiration(DateTimeOffset.Now.AddHours(1));
+
+ return new Lazy(() => {
+ logger.LogInformation("Fetching default branch for {RepositoryOwner}/{RepositoryName}",
+ repositoryOwner,
+ repositoryName);
+
+ var task = gitHubClient.Repository.Get(owner: repositoryOwner, name: repositoryName);
+ task.Wait();
+ return task.Result.DefaultBranch;
+ });
+ });
+
+ logger.LogInformation("Fetching HEAD of [{BaseBranchName}] branch of {RepositoryOwner}/{RepositoryName}",
+ defaultBranchName.Value,
+ repositoryOwner,
+ repositoryName);
+ var baseCommitSha = (await gitHubClient.Repository.Branch.Get(owner: repositoryOwner,
+ name: repositoryName,
+ branch: defaultBranchName.Value))
+ .Commit.Sha;
+
+ var baseTreeSha = cache.GetOrCreate>($"{repositoryOwner}/{repositoryName}",
+ entry => new Lazy(() => {
+ entry.SetSlidingExpiration(TimeSpan.FromDays(1));
+
+ logger.LogInformation("Fetching tree ID for {RepositoryOwner}/{RepositoryName}#{BaseCommitId}",
+ repositoryOwner,
+ repositoryName,
+ baseCommitSha);
+
+ var task = gitHubClient.Repository.Commit.Get(owner: repositoryOwner,
+ name: repositoryName,
+ reference: baseCommitSha);
+ task.Wait();
+ return task.Result.Commit.Tree.Sha;
+ }));
+
+ return new GitHubPullRequestCreationState(defaultBranchName.Value, baseCommitSha, baseTreeSha.Value);
+ }
+
+ private async Task FetchManifestAtRef(string reference)
+ {
+ logger.LogInformation("Fetching {ManifestPath} file from {RepositoryOwner}/{RepositoryName}#{BaseCommitId}",
+ apisDotJsonPath,
+ repositoryOwner,
+ repositoryName,
+ reference);
+
+ var contents = await gitHubClient.Repository.Content.GetAllContentsByRef(owner: repositoryOwner,
+ name: repositoryName,
+ path: apisDotJsonPath,
+ reference: reference);
+
+ // Because the previous API call gave a path to a specific file, the returned array will have 1 member.
+ return JsonSerializer.Deserialize(contents[0].Content,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+
+ private async Task CreateBlobFromManifest(Manifest manifest)
+ {
+ logger.LogInformation("Uploading manifest as a git blob");
+
+ var blob = await gitHubClient.Git.Blob.Create(owner: repositoryOwner,
+ name: repositoryName,
+ newBlob: new NewBlob() {
+ Encoding = EncodingType.Utf8,
+ Content = JsonSerializer.Serialize(manifest, new JsonSerializerOptions {
+ WriteIndented = true,
+ // Make sure null values are omitted from the output instead of being rendered as `null`
+ IgnoreNullValues = true,
+ // Without the unsafe encoder, ampersands get rendered as a UTF-8 escape sequence (`\u0026`)
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ }) + "\n",
+ });
+ return blob.Sha;
+ }
+
+ private async Task CreateTreeFromManifestBlob(string baseTreeId, string blobId)
+ {
+ logger.LogInformation("Creating a new tree replacing manifest with blob [{BlobId}] at [{BaseTreeId}]",
+ blobId,
+ baseTreeId);
+
+ var newTree = new NewTree() { BaseTree = baseTreeId };
+ newTree.Tree.Add(new NewTreeItem() {
+ Path = apisDotJsonPath,
+ Mode = "100644",
+ Type = TreeType.Blob,
+ Sha = blobId,
+ });
+ var tree = await gitHubClient.Git.Tree.Create(owner: repositoryOwner,
+ name: repositoryName,
+ newTree: newTree);
+ return tree.Sha;
+ }
+
+ private async Task CreateCommitFromTree(string commitMessage, string baseCommitId, string treeId)
+ {
+ logger.LogInformation("Creating commit from parent [{BaseCommitId}] and tree [{TreeId}]",
+ baseCommitId,
+ treeId);
+
+ var commit = await gitHubClient.Git.Commit.Create(owner: repositoryOwner,
+ name: repositoryName,
+ commit: new NewCommit(message: commitMessage, tree: treeId, parents: new [] { baseCommitId }));
+ return commit.Sha;
+ }
+
+ private async Task CreateBranchFromCommit(string branchName, string commitId)
+ {
+ logger.LogInformation("Creating branch [{BranchName}] at commit [{CommitId}]", branchName, commitId);
+
+ var branch = await gitHubClient.Git.Reference.Create(owner: repositoryOwner,
+ name: repositoryName,
+ reference: new NewReference(reference: $"refs/heads/{branchName}", sha: commitId));
+ return branchName;
+ }
+
+ private async Task CreatePullRequest(
+ string title,
+ string body,
+ string baseBranchName,
+ string headBranchName
+ ) {
+ logger.LogInformation("Opening pull request to merge [{BranchName}] into [{BaseBranchName}]",
+ headBranchName,
+ baseBranchName);
+
+ var pullRequest = await gitHubClient.Repository.PullRequest.Create(owner: repositoryOwner,
+ name: repositoryName,
+ newPullRequest: new NewPullRequest(title: title, head: headBranchName, baseRef: baseBranchName) {
+ Body = body,
+ });
+
+ return pullRequest.Url;
+ }
+
+ private async Task Rollback(string contextId, GitHubPullRequestCreationState state)
+ {
+ if (state.BranchName != null)
+ {
+ logger.LogInformation("Deleting branch [{BranchName}]", state.BranchName);
+
+ await gitHubClient.Git.Reference.Delete(owner: repositoryOwner,
+ name: repositoryName,
+ reference: $"refs/heads/{state.BranchName}");
+ }
+
+ // GitHub doesn't allow trees, blobs, or commits to be deleted via the API. Unreferenced objects will
+ // eventually be garbage collected (typically after two weeks).
+ logger.LogInformation("Workflow successfully rolled back");
+ }
+
+ private static string defaultIdProvider(Api api)
+ {
+ return Guid.NewGuid().ToString();
+ }
+ }
+
+ internal class GitHubPullRequestCreationState
+ {
+ internal string BaseBranchName { get; }
+ internal string BaseCommitId { get; }
+ internal string BaseTreeId { get; }
+ internal string? BlobId { get; set; }
+ internal string? TreeId { get; set; }
+ internal string? CommitId { get; set; }
+ internal string? BranchName { get; set; }
+
+ internal GitHubPullRequestCreationState(string baseBranchName, string baseCommitId, string baseTreeId)
+ {
+ BaseBranchName = baseBranchName;
+ BaseCommitId = baseCommitId;
+ BaseTreeId = baseTreeId;
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Services/ISubmissionService.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Services/ISubmissionService.cs
new file mode 100644
index 0000000..82c8f2c
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Services/ISubmissionService.cs
@@ -0,0 +1,11 @@
+using Gov.Apis.SubmissionEndpoint.Models;
+using Gov.Apis.SubmissionEndpoint.Models.ApisDotJson;
+using System.Threading.Tasks;
+
+namespace Gov.Apis.SubmissionEndpoint.Services
+{
+ public interface ISubmissionService
+ {
+ Task Submit(Api api);
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/Startup.cs b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Startup.cs
new file mode 100644
index 0000000..2f8d67d
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/Startup.cs
@@ -0,0 +1,73 @@
+using Gov.Apis.SubmissionEndpoint.Services;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+using Octokit;
+using System;
+
+namespace Gov.Apis.SubmissionEndpoint
+{
+ public class Startup
+ {
+ public Startup(IConfiguration configuration)
+ {
+ Configuration = configuration;
+ }
+
+ public IConfiguration Configuration { get; }
+
+ // This method gets called by the runtime. Use this method to add services to the container.
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddMemoryCache();
+
+ services.AddScoped(s => new GitHubClient(
+ productInformation: new ProductHeaderValue(Configuration["GITHUB_USER"])) {
+ Credentials = new Credentials(Configuration["GITHUB_USER"], Configuration["GITHUB_ACCESS_TOKEN"]),
+ });
+
+ services.AddScoped(s => new GitHubSubmissionService(
+ logger: s.GetRequiredService>(),
+ gitHubClient: s.GetRequiredService(),
+ cache: s.GetRequiredService(),
+ repositoryOwner: Configuration["REPOSITORY_OWNER"] ?? "usds",
+ repositoryName: "apis.gov",
+ apisDotJsonPath: "docs/apis.json"));
+
+ services.AddCors(options => {
+ options.AddDefaultPolicy(builder => {
+ builder.WithOrigins("https://usds.github.io")
+ .WithHeaders(HeaderNames.ContentType)
+ .SetPreflightMaxAge(TimeSpan.FromMinutes(15));
+ });
+ });
+
+ services.AddControllers(options => {
+ options.ReturnHttpNotAcceptable = true;
+ });
+ }
+
+ // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ }
+
+ app.UseRouting();
+
+ app.UseCors();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/appsettings.Development.json b/submission-endpoint/Gov.Apis.SubmissionEndpoint/appsettings.Development.json
new file mode 100644
index 0000000..8983e0f
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/submission-endpoint/Gov.Apis.SubmissionEndpoint/appsettings.json b/submission-endpoint/Gov.Apis.SubmissionEndpoint/appsettings.json
new file mode 100644
index 0000000..81ff877
--- /dev/null
+++ b/submission-endpoint/Gov.Apis.SubmissionEndpoint/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/submission-endpoint/Procfile b/submission-endpoint/Procfile
deleted file mode 100644
index 1da0cd6..0000000
--- a/submission-endpoint/Procfile
+++ /dev/null
@@ -1 +0,0 @@
-web: node index.js
diff --git a/submission-endpoint/handlers/constants.js b/submission-endpoint/handlers/constants.js
deleted file mode 100644
index 47d1fb0..0000000
--- a/submission-endpoint/handlers/constants.js
+++ /dev/null
@@ -1,3 +0,0 @@
-module.exports = {
- STATIC_SITE_ORIGIN: '*', // replace me with the real URL plz!
-};
diff --git a/submission-endpoint/handlers/httpError.js b/submission-endpoint/handlers/httpError.js
deleted file mode 100644
index a149d2a..0000000
--- a/submission-endpoint/handlers/httpError.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * @param {string} message
- * @param {number} statusCode
- */
-function httpError(message, statusCode) {
- const error = new Error(message);
- error.statusCode = statusCode;
- return error;
-}
-
-exports.httpError = httpError;
diff --git a/submission-endpoint/handlers/index.js b/submission-endpoint/handlers/index.js
deleted file mode 100644
index 5829ad4..0000000
--- a/submission-endpoint/handlers/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const rootHandler = require('./rootHandler');
-const rootCorsHandler = require('./rootCorsHandler');
-
-exports.handlers = [
- rootHandler,
- rootCorsHandler,
-];
diff --git a/submission-endpoint/handlers/rootCorsHandler.js b/submission-endpoint/handlers/rootCorsHandler.js
deleted file mode 100644
index 0760d40..0000000
--- a/submission-endpoint/handlers/rootCorsHandler.js
+++ /dev/null
@@ -1,13 +0,0 @@
-const {STATIC_SITE_ORIGIN} = require('./constants');
-
-exports.path = '/';
-exports.method = 'options';
-exports.handlerFunc = (req, res) => {
- res.writeHead(200, {
- 'Access-Control-Allow-Origin': STATIC_SITE_ORIGIN,
- 'Access-Control-Allow-Methods': 'POST',
- 'Access-Control-Allow-Headers': 'Content-Type',
- 'Access-Control-Max-Age': 86400,
- });
- res.end();
-};
diff --git a/submission-endpoint/handlers/rootHandler.js b/submission-endpoint/handlers/rootHandler.js
deleted file mode 100644
index 206d75f..0000000
--- a/submission-endpoint/handlers/rootHandler.js
+++ /dev/null
@@ -1,119 +0,0 @@
-const {STATIC_SITE_ORIGIN} = require('./constants');
-const {httpError} = require('./httpError');
-const {isValidApisJsonDocument} = require('../validator');
-
-const MAX_UPLOAD_SECONDS = 120;
-const MAX_DOCUMENT_SIZE = 2 * 1024 * 1024;
-const ERROR_HEADERS = {
- 'Content-Type': 'application/json',
- 'Access-Control-Allow-Origin': STATIC_SITE_ORIGIN,
-}
-
-exports.path = '/';
-exports.method = 'POST';
-exports.handlerFunc = (req, res) => {
- try {
- validateRequestHeaders(req);
- } catch (e) {
- res.writeHead(e.statusCode || 400, ERROR_HEADERS);
- res.end(JSON.stringify({message: e.message}));
- return;
- }
-
- readRequestBody(req)
- .then(Buffer.from)
- .then(buf => buf.toString())
- .then(JSON.parse)
- .then(parsed => {
- if (isValidApisJsonDocument(parsed)) {
- return parsed;
- }
-
- throw httpError('Submitted API JSON did not match the schema', 422);
- }).then(parsedAndValidated => {
- res.writeHead(200, ERROR_HEADERS);
- res.end(JSON.stringify(parsedAndValidated));
- }).catch(err => {
- console.error(err);
- res.writeHead(err.statusCode || 500, ERROR_HEADERS);
- res.end(JSON.stringify({message: err.message}));
- });
-};
-
-/**
- * @param {http.IncomingMessage} req
- */
-function validateRequestHeaders(req) {
- // Only allow JSON bodies
- if (req.headers["content-type"].toLowerCase() !== 'application/json') {
- throw httpError('Only JSON payloads are accepted.', 415);
- }
-
- // Do some pre-emptive max size enforcement
- if (!req.headers["content-length"]) {
- throw httpError(
- 'Requests must include JSON payloads with a reported content length.',
- 411
- );
- } else if (req.headers["content-length"] > MAX_DOCUMENT_SIZE) {
- throw httpError(
- `The submitted document is ${
- req.headers["content-length"]
- }; documents may not exceed ${MAX_DOCUMENT_SIZE} bytes.`,
- 413
- );
- }
-}
-
-/**
- * @param {http.IncomingMessage} req
- * @returns {Promise}
- */
-function readRequestBody(req) {
- const readStartedAt = process.hrtime();
- const expectedLength = Number.parseInt(req.headers["content-length"], 10) || 0;
- const buffer = new Uint8Array(expectedLength);
- let bytesRead = 0;
-
- return new Promise((resolve, reject) => {
- req.setTimeout(MAX_UPLOAD_SECONDS * 1000, () => {
- reject(httpError(`Socket timed out after ${MAX_UPLOAD_SECONDS} seconds.`,
- 408));
- });
-
- const chunks = [];
- req.on('data', (chunk) => {
- const timeElapsed = process.hrtime(readStartedAt);
-
- if (timeElapsed[0] >= MAX_UPLOAD_SECONDS) {
- reject(httpError(`Request body not fully read within ${
- MAX_UPLOAD_SECONDS} seconds`, 408));
- return;
- }
-
- for (const byte of chunk) {
- buffer[bytesRead++] = byte;
- if (bytesRead > expectedLength) {
- reject(httpError(`Request body has exceeded its stated length of ${
- expectedLength} bytes`, 413));
- return;
- }
- }
- });
-
- req.on('end', () => {
- if (bytesRead != expectedLength) {
- reject(httpError(`Request body did not match its reported length of ${
- expectedLength}. Only ${bytesRead} were received.`, 400));
- return;
- }
-
- resolve(buffer);
- });
-
- req.on('error', err => {
- err.statusCode = 500;
- reject(err);
- });
- });
-}
diff --git a/submission-endpoint/index.js b/submission-endpoint/index.js
deleted file mode 100644
index 3b053db..0000000
--- a/submission-endpoint/index.js
+++ /dev/null
@@ -1,32 +0,0 @@
-"use strict";
-
-const {handlers} = require('./handlers');
-const http = require('http');
-const {URL} = require('url');
-const port = process.env.PORT || 2014;
-
-const handlerMap = {};
-for (const handler of handlers) {
- const pathEntry = handlerMap[handler.path] || {};
- if (pathEntry[handler.method.toLowerCase()]) {
- throw new Error(`Conflicting routes defined for ${
- handler.method.toUpperCase()} ${handler.path}`);
- }
-
- pathEntry[handler.method.toLowerCase()] = handler.handlerFunc;
- handlerMap[handler.path] = pathEntry;
-}
-
-const server = http.createServer((req, res) => {
- const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
- const handler = (handlerMap[parsedUrl.pathname] || {})[req.method.toLowerCase()];
- if (!handler) {
- res.writeHead(404);
- res.end();
- return;
- }
-
- handler(req, res);
-});
-
-server.listen(port);
diff --git a/submission-endpoint/manifest.yml b/submission-endpoint/manifest.yml
deleted file mode 100644
index 44cfc0d..0000000
--- a/submission-endpoint/manifest.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-applications:
- - apis-dot-gov-submission-endpoint:
- random-route: true
- buildpacks:
- - nodejs_buildpack
- memory: 128M
diff --git a/submission-endpoint/package.json b/submission-endpoint/package.json
deleted file mode 100644
index 1de17aa..0000000
--- a/submission-endpoint/package.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "@apis.gov/submission-endpoint",
- "version": "0.0.1",
- "description": "An endpoint submission endpoint for apis.gov",
- "main": "index.js",
- "private": true,
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/usds/apis.gov.git"
- },
- "author": "USDS Hackathon apis.gov team",
- "license": "MIT",
- "bugs": {
- "url": "https://github.com/usds/apis.gov/issues"
- },
- "homepage": "https://github.com/usds/apis.gov#readme"
-}
diff --git a/submission-endpoint/submission-endpoint.sln b/submission-endpoint/submission-endpoint.sln
new file mode 100644
index 0000000..8f7b1eb
--- /dev/null
+++ b/submission-endpoint/submission-endpoint.sln
@@ -0,0 +1,48 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 15
+VisualStudioVersion = 15.0.26124.0
+MinimumVisualStudioVersion = 15.0.26124.0
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gov.Apis.SubmissionEndpoint", "Gov.Apis.SubmissionEndpoint\Gov.Apis.SubmissionEndpoint.csproj", "{00577531-8618-4273-8841-D5DB84E6A18C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gov.Apis.SubmissionEndpoint.Tests", "Gov.Apis.SubmissionEndpoint.Tests\Gov.Apis.SubmissionEndpoint.Tests.csproj", "{4170D08F-84D6-4C25-93FE-53B76199DEE2}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Debug|x64.Build.0 = Debug|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Debug|x86.Build.0 = Debug|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Release|x64.ActiveCfg = Release|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Release|x64.Build.0 = Release|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Release|x86.ActiveCfg = Release|Any CPU
+ {00577531-8618-4273-8841-D5DB84E6A18C}.Release|x86.Build.0 = Release|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Debug|x64.Build.0 = Debug|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Debug|x86.Build.0 = Debug|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Release|x64.ActiveCfg = Release|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Release|x64.Build.0 = Release|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Release|x86.ActiveCfg = Release|Any CPU
+ {4170D08F-84D6-4C25-93FE-53B76199DEE2}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/submission-endpoint/validator.js b/submission-endpoint/validator.js
deleted file mode 100644
index 3c793cd..0000000
--- a/submission-endpoint/validator.js
+++ /dev/null
@@ -1 +0,0 @@
-exports.isValidApisJsonDocument = (document) => true;