Skip to content

Commit

Permalink
Merge pull request #121 from BigDaddy1337/feature/CSHARP-204-support-…
Browse files Browse the repository at this point in the history
…json-attr

feature(autodoc): OpenRpcDoc: support JsonPropertyName, JsonConverter, JsonIgnore attributes
  • Loading branch information
OptimumDev authored Oct 9, 2024
2 parents 4e81435 + 1b31871 commit 42a534d
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 40 deletions.
3 changes: 3 additions & 0 deletions src/Tochka.JsonRpc.OpenRpc/Models/XmlDocValuesWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Tochka.JsonRpc.OpenRpc.Models;

internal record XmlDocValuesWrapper(string? Summary, string? Remarks);
34 changes: 30 additions & 4 deletions src/Tochka.JsonRpc.OpenRpc/Services/JsonSchemaBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
using Json.Schema;
using Tochka.JsonRpc.OpenRpc.Models;

namespace Tochka.JsonRpc.OpenRpc.Services;

internal static class JsonSchemaBuilderExtensions
{
public static JsonSchemaBuilder TryAppendTitle(this JsonSchemaBuilder builder, string? propertySummary)
private const string OpenRpcUiNewLine = "\n\r";

public static JsonSchemaBuilder AppendXmlDocs(this JsonSchemaBuilder builder, XmlDocValuesWrapper xmlDocs)
{
if (propertySummary is { Length: > 0 })
var documentation = xmlDocs.Summary;

if (!string.IsNullOrEmpty(xmlDocs.Remarks))
{
builder.Title(propertySummary);
if (!string.IsNullOrEmpty(documentation))
{
documentation += OpenRpcUiNewLine;
}

documentation += xmlDocs.Remarks;
}

if (!string.IsNullOrEmpty(documentation))
{
// OpenRPC UI does not accept line breaks without carriage return
documentation = documentation.Replace(Environment.NewLine, OpenRpcUiNewLine);

var newLineIndex = documentation.IndexOf(OpenRpcUiNewLine, StringComparison.Ordinal);
if (newLineIndex >= 0)
{
builder.Title(documentation[..newLineIndex] + "...")
.Description(documentation);
}
else
{
builder.Title(documentation);
}
}

return builder;
}
}
119 changes: 83 additions & 36 deletions src/Tochka.JsonRpc.OpenRpc/Services/OpenRpcSchemaGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.Collections;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using JetBrains.Annotations;
using Json.Schema;
using Json.Schema.Generation;
using Namotion.Reflection;
using Tochka.JsonRpc.Common;
using Tochka.JsonRpc.OpenRpc.Models;

namespace Tochka.JsonRpc.OpenRpc.Services;

Expand Down Expand Up @@ -35,19 +37,21 @@ public class OpenRpcSchemaGenerator : IOpenRpcSchemaGenerator
public JsonSchema CreateOrRef(Type type, string methodName, JsonSerializerOptions jsonSerializerOptions) =>
CreateOrRefInternal(type, methodName, null, jsonSerializerOptions);

private JsonSchema CreateOrRefInternal(Type type, string methodName, string? propertySummary, JsonSerializerOptions jsonSerializerOptions)
private JsonSchema CreateOrRefInternal(Type type, string methodName, PropertyInfo? property, JsonSerializerOptions jsonSerializerOptions)
{
var clearType = TryUnwrapNullableType(type);
var clearTypeName = GetClearTypeName(methodName, clearType);

return BuildSchema(clearType, clearTypeName, methodName, propertySummary, jsonSerializerOptions);
return BuildSchema(clearType, clearTypeName, methodName, property, jsonSerializerOptions);
}

private JsonSchema BuildSchema(Type type, string typeName, string methodName, string? propertySummary, JsonSerializerOptions jsonSerializerOptions)
private JsonSchema BuildSchema(Type type, string typeName, string methodName, PropertyInfo? property, JsonSerializerOptions jsonSerializerOptions)
{
var propertyXmlDocs = new XmlDocValuesWrapper(property?.GetXmlDocsSummary(), property?.GetXmlDocsRemarks());

if (registeredSchemas.ContainsKey(typeName) || registeredSchemaKeys.Contains(typeName))
{
return CreateRefSchema(typeName, propertySummary);
return CreateRefSchema(typeName, propertyXmlDocs);
}

var itemType = type.GetEnumerableItemType();
Expand All @@ -56,25 +60,32 @@ private JsonSchema BuildSchema(Type type, string typeName, string methodName, st
var collectionScheme = new JsonSchemaBuilder()
.Type(SchemaValueType.Array)
.Items(CreateOrRefInternal(itemType, methodName, null, jsonSerializerOptions))
.TryAppendTitle(propertySummary)
.AppendXmlDocs(propertyXmlDocs)
.BuildWithoutUri();
// returning schema itself if it's collection
return collectionScheme;
}

if (type.IsEnum)
{
List<string> enumValues = [];
var enumSerializerOptions = GetSerializerOptionsByConverterAttribute(property) ?? jsonSerializerOptions;
foreach (var val in type.GetEnumValues())
{
enumValues.Add(JsonSerializer.Serialize(val, enumSerializerOptions).Replace("\"", string.Empty));
}
var enumSchema = new JsonSchemaBuilder()
.Enum(type.GetEnumNames().Select(jsonSerializerOptions.ConvertName))
.Enum(enumValues)
.AppendXmlDocs(new XmlDocValuesWrapper(type.GetXmlDocsSummary(), type.GetXmlDocsRemarks()))
.BuildWithoutUri();
RegisterSchema(typeName, enumSchema);
// returning ref if it's enum or regular type with properties
return CreateRefSchema(typeName, propertySummary);
return CreateRefSchema(typeName, propertyXmlDocs);
}

var simpleTypeSchema = new JsonSchemaBuilder()
.FromType(type)
.TryAppendTitle(propertySummary)
.AppendXmlDocs(propertyXmlDocs)
.BuildWithoutUri();
// can't check type.GetProperties() here because simple types have properties too
if (simpleTypeSchema.GetProperties() == null)
Expand All @@ -89,7 +100,7 @@ private JsonSchema BuildSchema(Type type, string typeName, string methodName, st
var simpleStringSchema = new JsonSchemaBuilder()
.Type(SchemaValueType.String)
.Format(format)
.TryAppendTitle(propertySummary)
.AppendXmlDocs(propertyXmlDocs)
.BuildWithoutUri();
return simpleStringSchema;
}
Expand All @@ -102,70 +113,106 @@ private JsonSchema BuildSchema(Type type, string typeName, string methodName, st

var jsonSchemaBuilder = new JsonSchemaBuilder()
.Type(SchemaValueType.Object)
.Properties(propertiesSchemas);
.Properties(propertiesSchemas)
.AppendXmlDocs(new XmlDocValuesWrapper(type.GetXmlDocsSummary(), type.GetXmlDocsRemarks()));

if (requiredProperties is not null)
{
jsonSchemaBuilder.Required(requiredProperties);
}

var objectSchema = jsonSchemaBuilder.BuildWithoutUri();
RegisterSchema(typeName, objectSchema);
return CreateRefSchema(typeName, propertySummary);
return CreateRefSchema(typeName, propertyXmlDocs);
}

private void RegisterSchema(string key, JsonSchema schema)
{
registeredSchemaKeys.Add(key);
registeredSchemas[key] = schema;
}

private Dictionary<string, JsonSchema> BuildPropertiesSchemas(Type type, string typeName, string methodName, JsonSerializerOptions jsonSerializerOptions)
{
Dictionary<string, JsonSchema> schemas = new();
var properties = type.GetProperties();

foreach (var property in properties)
{
if (property.GetCustomAttribute<JsonIgnoreAttribute>() is not null)
{
continue;
}

var jsonPropertyName = GetJsonPropertyName(property, jsonSerializerOptions);

TrySetRequiredState(property, jsonPropertyName, typeName, methodName, jsonSerializerOptions);
var schema = CreateOrRefInternal(property.PropertyType, methodName, property, jsonSerializerOptions);
schemas.Add(jsonPropertyName, schema);
}

private Dictionary<string, JsonSchema> BuildPropertiesSchemas(Type type, string typeName, string methodName, JsonSerializerOptions jsonSerializerOptions) =>
type
.GetProperties()
.ToDictionary(p => jsonSerializerOptions.ConvertName(p.Name),
p =>
{
TrySetRequiredState(p, typeName, methodName, jsonSerializerOptions);
return CreateOrRefInternal(p.PropertyType, methodName, p.GetXmlDocsSummary(), jsonSerializerOptions);
});

private void TrySetRequiredState(PropertyInfo propertyInfo, string typeName, string methodName, JsonSerializerOptions jsonSerializerOptions)
return schemas;
}

private void TrySetRequiredState(PropertyInfo property, string jsonPropertyName, string typeName, string methodName, JsonSerializerOptions jsonSerializerOptions)
{
if (propertyInfo.PropertyType.IsGenericType)
if (property.PropertyType.IsGenericType)
{
var propertiesInGenericType = propertyInfo.PropertyType.GetProperties();
var propertiesInGenericType = property.PropertyType.GetProperties();

var genericPropertiesContext = nullabilityInfoContext.Create(propertyInfo);
var clearGenericTypeName = GetClearTypeName(methodName, propertyInfo.PropertyType);
var genericPropertiesContext = nullabilityInfoContext.Create(property);
var clearGenericTypeName = GetClearTypeName(methodName, property.PropertyType);
var propsNullabilityInfo = genericPropertiesContext.GenericTypeArguments.Zip(propertiesInGenericType,
(nullabilityInfo, propInfo) => new { nullabilityInfo, propInfo });

foreach (var requiredPropState in propsNullabilityInfo.Where(x => x.nullabilityInfo.ReadState is NullabilityState.NotNull))
{
TryAddRequiredMember(clearGenericTypeName, requiredPropState.propInfo.Name, jsonSerializerOptions);
var innerJsonPropertyName = GetJsonPropertyName(requiredPropState.propInfo, jsonSerializerOptions);
TryAddRequiredMember(clearGenericTypeName, innerJsonPropertyName);
}
}

var propContext = nullabilityInfoContext.Create(propertyInfo);
var propContext = nullabilityInfoContext.Create(property);
var required = propContext.ReadState is NullabilityState.NotNull;
if (required)
{
TryAddRequiredMember(typeName, propertyInfo.Name, jsonSerializerOptions);
TryAddRequiredMember(typeName, jsonPropertyName);
}
}

private void TryAddRequiredMember(string typeName, string propertyName, JsonSerializerOptions jsonSerializerOptions)
private void TryAddRequiredMember(string typeName, string jsonPropertyName)
{
var clearPropertyName = jsonSerializerOptions.ConvertName(propertyName);
var requiredProperties = requiredPropsForSchemas.GetValueOrDefault(typeName) ?? new List<string>();
if (!requiredProperties.Contains(clearPropertyName))
var requiredProperties = requiredPropsForSchemas.GetValueOrDefault(typeName) ?? [];
if (!requiredProperties.Contains(jsonPropertyName))
{
requiredProperties.Add(clearPropertyName);
requiredProperties.Add(jsonPropertyName);
}

requiredPropsForSchemas.TryAdd(typeName, requiredProperties);
}

private static string GetJsonPropertyName(PropertyInfo property, JsonSerializerOptions jsonSerializerOptions)
{
return property.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? jsonSerializerOptions.ConvertName(property.Name);
}

private static JsonSerializerOptions? GetSerializerOptionsByConverterAttribute(PropertyInfo? property)
{
var converterAttribute = property?.GetCustomAttribute<JsonConverterAttribute>();
if (converterAttribute is { ConverterType: {} converterType })
{
if (Activator.CreateInstance(converterType) is JsonConverter converterInstance)
{
var options = new JsonSerializerOptions();
options.Converters.Add(converterInstance);
return options;
}
}

return null;
}

private static Type TryUnwrapNullableType(Type type) => Nullable.GetUnderlyingType(type) ?? type;

private static string GetClearTypeName(string methodName, Type clearType)
Expand Down Expand Up @@ -198,11 +245,11 @@ private static string GetClearTypeName(string methodName, Type clearType)
return null;
}

private static JsonSchema CreateRefSchema(string typeName, string? propertySummary)
private static JsonSchema CreateRefSchema(string typeName, XmlDocValuesWrapper propertyXmlDocs)
{
var refSchemaBuilder = new JsonSchemaBuilder()
.Ref($"#/components/schemas/{typeName}")
.TryAppendTitle(propertySummary);
.AppendXmlDocs(propertyXmlDocs);

return refSchemaBuilder.BuildWithoutUri();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions;
using Json.Schema;
using Json.Schema.Generation;
Expand Down Expand Up @@ -869,6 +870,36 @@ public void CreateOrRef_TypeWithGenericPropertyHasCorrectRequiredState_ChildGene

schemaGenerator.GetAllSchemas().Should().BeEquivalentTo(expectedRegistrations);
}

[Test]
public void CreateOrRef_SystemTextJsonAttributesHandling()
{
var type = typeof(TypeWithJsonAttributes);
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };

var actualSchema = schemaGenerator.CreateOrRef(type, MethodName, jsonSerializerOptions);

var typeName = $"{MethodName} {nameof(TypeWithJsonAttributes)}";
var typeNameInnerEnum = $"{MethodName} {nameof(TypeWithJsonAttributesEnum)}";

actualSchema.Should().BeEquivalentTo(new JsonSchemaBuilder().Ref($"#/components/schemas/{typeName}").BuildWithoutUri());

var expectedSchemas = new Dictionary<string, JsonSchema>
{
[typeNameInnerEnum] = new JsonSchemaBuilder().Enum("MyEnumValue").BuildWithoutUri(),
[typeName] = new JsonSchemaBuilder()
.Type(SchemaValueType.Object)
.Properties(new Dictionary<string, JsonSchema>
{
["custom_name_1"] = new JsonSchemaBuilder().Type(SchemaValueType.String).BuildWithoutUri(),
["custom_name_2"] = new JsonSchemaBuilder().Ref($"#/components/schemas/{typeNameInnerEnum}").BuildWithoutUri()
})
.Required("custom_name_1", "custom_name_2")
.BuildWithoutUri()
};

schemaGenerator.GetAllSchemas().Should().BeEquivalentTo(expectedSchemas);
}

private const string MethodName = "methodName";

Expand Down Expand Up @@ -991,4 +1022,22 @@ private sealed record AnotherTypeWithProperties(bool BoolProperty);
private sealed record TypeWithSimpleProperties(DateTime DateTime, DateTimeOffset DateTimeOffset, DateOnly DateOnly, TimeOnly TimeOnly, TimeSpan TimeSpan, Guid Guid);

private sealed record CustomSimpleType;

private class TypeWithJsonAttributes
{
[JsonPropertyName("custom_name_1")]
public string Prop1 { get; set; }

[JsonIgnore]
public string Prop2 { get; set; }

[JsonPropertyName("custom_name_2")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public TypeWithJsonAttributesEnum Prop3 { get; set; }
}

private enum TypeWithJsonAttributesEnum
{
MyEnumValue
}
}

0 comments on commit 42a534d

Please sign in to comment.