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;