Improve service reference filenames and class names (#7447)

- #4927
- fully-sanitize class names and filenames
  - use aspnet/AspNetCore-Tooling's `CSharpIdentifier` class
- default metadata in sequence [URI or project&document name ->] `%(DocumentPath)` -> `%(OutputPath)` -> `%(ClassName)`
  - if user sets metadata explicitly, the override affects defaults later in the sequence
- separate some nested validations and defaulting steps
  - provide default `%(DocumentName}` even if `%(DocumentPath}` is set explicitly
  - validate URI is absolute even if `%(DocumentPath}` is set explicitly

other:
- don't write out an empty Open API / Swagger file

nits:
- do not use default `%(DocumentName}` in default `%(DocumentPath)` for `<ServiceProjectReference>` items
- do not use empty URI path or query string in default `%(DocumentPath)` for `<ServiceUriReference>` items
This commit is contained in:
Doug Bunting 2019-02-19 16:05:54 -08:00 committed by GitHub
parent c725089e8b
commit 8d6d300bfc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 182 additions and 73 deletions

View File

@ -0,0 +1,55 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Globalization;
using System.Text;
// Copied from
// https://github.com/aspnet/AspNetCore-Tooling/blob/master/src/Razor/src/Microsoft.AspNetCore.Razor.Language/CSharpIdentifier.cs
namespace Microsoft.Extensions.ApiDescription.Tasks
{
internal static class CSharpIdentifier
{
// CSharp Spec §2.4.2
private static bool IsIdentifierStart(char character)
{
return char.IsLetter(character) ||
character == '_' ||
CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.LetterNumber;
}
public static bool IsIdentifierPart(char character)
{
return char.IsDigit(character) ||
IsIdentifierStart(character) ||
IsIdentifierPartByUnicodeCategory(character);
}
private static bool IsIdentifierPartByUnicodeCategory(char character)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
return category == UnicodeCategory.NonSpacingMark || // Mn
category == UnicodeCategory.SpacingCombiningMark || // Mc
category == UnicodeCategory.ConnectorPunctuation || // Pc
category == UnicodeCategory.Format; // Cf
}
public static string SanitizeIdentifier(string inputName)
{
if (!IsIdentifierStart(inputName[0]) && IsIdentifierPart(inputName[0]))
{
inputName = "_" + inputName;
}
var builder = new StringBuilder(inputName.Length);
for (var i = 0; i < inputName.Length; i++)
{
var ch = inputName[i];
builder.Append(IsIdentifierPart(ch) ? ch : '_');
}
return builder.ToString();
}
}
}

View File

@ -78,30 +78,13 @@ namespace Microsoft.Extensions.ApiDescription.Tasks
Log.LogError(Resources.FormatInvalidEmptyMetadataValue("CodeGenerator", type, item.ItemSpec));
}
var className = item.GetMetadata("ClassName");
if (string.IsNullOrEmpty(className))
{
var filename = item.GetMetadata("Filename");
className = $"{filename}Client";
if (char.IsLower(className[0]))
{
className = char.ToUpper(className[0]) + className.Substring(startIndex: 1);
}
MetadataSerializer.SetMetadata(newItem, "ClassName", className);
}
var @namespace = item.GetMetadata("Namespace");
if (string.IsNullOrEmpty(@namespace))
{
MetadataSerializer.SetMetadata(newItem, "Namespace", Namespace);
}
var outputPath = item.GetMetadata("OutputPath");
if (string.IsNullOrEmpty(outputPath))
{
// No need to further sanitize this path.
var filename = item.GetMetadata("Filename");
var isTypeScript = codeGenerator.EndsWith(TypeScriptLanguageName, StringComparison.OrdinalIgnoreCase);
outputPath = $"{className}{(isTypeScript ? ".ts" : Extension)}";
outputPath = $"{filename}Client{(isTypeScript ? ".ts" : Extension)}";
}
// Place output file in correct directory (relative to project directory).
@ -119,6 +102,21 @@ namespace Microsoft.Extensions.ApiDescription.Tasks
MetadataSerializer.SetMetadata(newItem, "OutputPath", outputPath);
var className = item.GetMetadata("ClassName");
if (string.IsNullOrEmpty(className))
{
var outputFilename = Path.GetFileNameWithoutExtension(outputPath);
className = CSharpIdentifier.SanitizeIdentifier(outputFilename);
MetadataSerializer.SetMetadata(newItem, "ClassName", className);
}
var @namespace = item.GetMetadata("Namespace");
if (string.IsNullOrEmpty(@namespace))
{
MetadataSerializer.SetMetadata(newItem, "Namespace", Namespace);
}
// Add metadata which may be used as a property and passed to an inner build.
newItem.RemoveMetadata("SerializedMetadata");
newItem.SetMetadata("SerializedMetadata", MetadataSerializer.SerializeMetadata(newItem));

View File

@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Build.Framework;
@ -14,6 +15,9 @@ namespace Microsoft.Extensions.ApiDescription.Tasks
/// </summary>
public class GetProjectReferenceMetadata : Task
{
private static readonly char[] InvalidFilenameCharacters = Path.GetInvalidFileNameChars();
private static readonly string[] InvalidFilenameStrings = new[] { ".." };
/// <summary>
/// Default directory for DocumentPath values.
/// </summary>
@ -53,22 +57,60 @@ namespace Microsoft.Extensions.ApiDescription.Tasks
item.ItemSpec));
}
var documentName = item.GetMetadata("DocumentName");
if (string.IsNullOrEmpty(documentName))
{
documentName = "v1";
MetadataSerializer.SetMetadata(newItem, "DocumentName", documentName);
}
var documentPath = item.GetMetadata("DocumentPath");
if (string.IsNullOrEmpty(documentPath))
{
var filename = item.GetMetadata("Filename");
var documentName = item.GetMetadata("DocumentName");
// No need to sanitize the filename since the project file exists.
var projectFilename = item.GetMetadata("Filename");
// Default document filename matches project filename unless given a non-default document name.
if (string.IsNullOrEmpty(documentName))
{
documentName = "v1";
// This is an odd (but allowed) case that would break the sanitize one-liner below. Also,
// ensure chosen name does not match the "v1" case.
documentPath = projectFilename + "_.json";
}
else if (string.Equals("v1", documentName, StringComparison.Ordinal))
{
documentPath = projectFilename + ".json";
}
else
{
// Sanitize the document name because it may contain almost any character, including illegal
// filename characters such as '/' and '?'. (Do not treat slashes as folder separators.)
var sanitizedDocumentName = string.Join("_", documentName.Split(InvalidFilenameCharacters));
while (sanitizedDocumentName.Contains(InvalidFilenameStrings[0]))
{
sanitizedDocumentName = string.Join(
".",
sanitizedDocumentName.Split(InvalidFilenameStrings, StringSplitOptions.None));
}
documentPath = $"{filename}.{documentName}.json";
documentPath = $"{projectFilename}_{sanitizedDocumentName}";
// Possible the document path already ends with .json. Don't duplicate that or a final period.
if (!documentPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
if (documentPath.EndsWith(".", StringComparison.Ordinal))
{
documentPath += "json";
}
else
{
documentPath += ".json";
}
}
}
}
documentPath = GetFullPath(documentPath);
MetadataSerializer.SetMetadata(newItem, "DocumentPath", documentPath);
if (!destinations.Add(documentPath))
{
// This case may occur when user is experimenting e.g. with multiple generators or options.
@ -76,6 +118,8 @@ namespace Microsoft.Extensions.ApiDescription.Tasks
Log.LogError(Resources.FormatDuplicateProjectDocumentPaths(documentPath));
}
MetadataSerializer.SetMetadata(newItem, "DocumentPath", documentPath);
// Add metadata which may be used as a property and passed to an inner build.
newItem.SetMetadata("SerializedMetadata", MetadataSerializer.SerializeMetadata(newItem));
}

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
@ -14,6 +15,9 @@ namespace Microsoft.Extensions.ApiDescription.Tasks
/// </summary>
public class GetUriReferenceMetadata : Task
{
private static readonly char[] InvalidFilenameCharacters = Path.GetInvalidFileNameChars();
private static readonly string[] InvalidFilenameStrings = new[] { ".." };
/// <summary>
/// Default directory for DocumentPath metadata values.
/// </summary>
@ -41,68 +45,74 @@ namespace Microsoft.Extensions.ApiDescription.Tasks
var newItem = new TaskItem(item);
outputs.Add(newItem);
var uri = item.ItemSpec;
var builder = new UriBuilder(uri);
if (!builder.Uri.IsAbsoluteUri)
{
Log.LogError($"{nameof(Inputs)} item '{uri}' is not an absolute URI.");
return false;
}
if (!string.Equals(Uri.UriSchemeHttp, builder.Scheme, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(Uri.UriSchemeHttps, builder.Scheme, StringComparison.OrdinalIgnoreCase))
{
Log.LogError($"{nameof(Inputs)} item '{uri}' does not have scheme {Uri.UriSchemeHttp} or " +
$"{Uri.UriSchemeHttps}.");
return false;
}
// If not specified, base filename on the URI.
var documentPath = item.GetMetadata("DocumentPath");
if (string.IsNullOrEmpty(documentPath))
{
var uri = item.ItemSpec;
var builder = new UriBuilder(uri);
if (!builder.Uri.IsAbsoluteUri)
// Default to a fairly long but identifiable and fairly unique filename.
var documentPathBuilder = new StringBuilder(builder.Host);
if (!string.IsNullOrEmpty(builder.Path) &&
!string.Equals("/", builder.Path, StringComparison.Ordinal))
{
Log.LogError($"{nameof(Inputs)} item '{uri}' is not an absolute URI.");
return false;
documentPathBuilder.Append(builder.Path);
}
if (!string.Equals(Uri.UriSchemeHttp, builder.Scheme, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(Uri.UriSchemeHttps, builder.Scheme, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrEmpty(builder.Query) &&
!string.Equals("?", builder.Query, StringComparison.Ordinal))
{
Log.LogError($"{nameof(Inputs)} item '{uri}' does not have scheme {Uri.UriSchemeHttp} or " +
$"{Uri.UriSchemeHttps}.");
return false;
documentPathBuilder.Append(builder.Query);
}
var host = builder.Host
.Replace("/", string.Empty)
.Replace("[", string.Empty)
.Replace("]", string.Empty)
.Replace(':', '_');
var path = builder.Path
.Replace("!", string.Empty)
.Replace("'", string.Empty)
.Replace("$", string.Empty)
.Replace("%", string.Empty)
.Replace("&", string.Empty)
.Replace("(", string.Empty)
.Replace(")", string.Empty)
.Replace("*", string.Empty)
.Replace("@", string.Empty)
.Replace("~", string.Empty)
.Replace('/', '_')
.Replace(':', '_')
.Replace(';', '_')
.Replace('+', '_')
.Replace('=', '_');
documentPath = host + path;
if (char.IsLower(documentPath[0]))
// Sanitize the string because it likely contains illegal filename characters such as '/' and '?'.
// (Do not treat slashes as folder separators.)
documentPath = documentPathBuilder.ToString();
documentPath = string.Join("_", documentPath.Split(InvalidFilenameCharacters));
while (documentPath.Contains(InvalidFilenameStrings[0]))
{
documentPath = char.ToUpper(documentPath[0]) + documentPath.Substring(startIndex: 1);
documentPath = string.Join(
".",
documentPath.Split(InvalidFilenameStrings, StringSplitOptions.None));
}
// URI might end with ".json". Don't duplicate that or a final period.
if (!documentPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
documentPath = $"{documentPath}.json";
if (documentPath.EndsWith(".", StringComparison.Ordinal))
{
documentPath += "json";
}
else
{
documentPath += ".json";
}
}
}
documentPath = GetFullPath(documentPath);
MetadataSerializer.SetMetadata(newItem, "DocumentPath", documentPath);
if (!destinations.Add(documentPath))
{
// This case may occur when user is experimenting e.g. with multiple code generators or options.
// May also occur when user accidentally duplicates DocumentPath metadata.
Log.LogError(Resources.FormatDuplicateUriDocumentPaths(documentPath));
}
MetadataSerializer.SetMetadata(newItem, "DocumentPath", documentPath);
}
Outputs = outputs.ToArray();

View File

@ -91,7 +91,7 @@
-->
<DocumentName />
<!--
Full path where the API description document is placed. Default filename is %(Filename).%(DocumentName).json.
Full path where the API description document is placed. Default filename is %(Filename)_%(DocumentName).json.
Filenames and relative paths (if explicitly set) are combined with $(ServiceProjectReferenceDirectory).
-->
<DocumentPath />
@ -116,7 +116,7 @@
</ServiceUriReference>
<ServiceFileReference>
<!-- Name of the class to generate. Defaults to %(Filename)Client but with an uppercase first letter. -->
<!-- Name of the class to generate. Defaults to match final segment of %(OutputPath). -->
<ClassName />
<!--
Code generator to use. Required and must end with "CSharp" or "TypeScript" (the currently-supported target
@ -130,7 +130,7 @@
<Namespace />
<!--
Path to place generated code. Code generator may interpret path as a filename or directory. Default filename or
folder name is %(ClassName).[cs|ts]. Filenames and relative paths (if explicitly set) are combined with
folder name is %(Filename)Client.[cs|ts]. Filenames and relative paths (if explicitly set) are combined with
$(ServiceFileReferenceDirectory). Final value (depending on $(ServiceFileReferenceDirectory)) is likely to be
a path relative to the client project.
-->

View File

@ -311,7 +311,7 @@
<SourceDocument>%(ServiceFileReference.FullPath)</SourceDocument>
</Compile>
<!-- Otherwise, add all descendent files with the expected extension. -->
<!-- Otherwise, add all descendant files with the expected extension. -->
<TypeScriptCompile Remove="@(_Directories -> '%(Identity)/**/*.ts')" />
<TypeScriptCompile Include="@(_Directories -> '%(Identity)/**/*.ts')">
<SourceDocument>%(_Directories.FullPath)</SourceDocument>

View File

@ -117,12 +117,14 @@ namespace Microsoft.Extensions.ApiDescription.Tool.Commands
}
writer.Flush();
stream.Position = 0L;
using (var outStream = File.Create(context.OutputPath))
if (stream.Length > 0L)
{
stream.CopyTo(outStream);
outStream.Flush();
stream.Position = 0L;
using (var outStream = File.Create(context.OutputPath))
{
stream.CopyTo(outStream);
outStream.Flush();
}
}
}