From 2b4b084c73d1a81a31154c9a143b72c9d7ca96d6 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 6 Feb 2021 22:35:57 +0100 Subject: [PATCH 1/4] Feature/flat docs (#643) * Simplify OpenAPI Closes #638 * Temp * Flat docs. --- .../ContentSchemaBuilder.cs | 44 ---- .../JsonSchemaExtensions.cs | 27 +- .../GenerateJsonSchema/JsonTypeVisitor.cs | 49 ++-- .../Assets/Queries/AssetQueryParser.cs | 66 ++--- .../Contents/Queries/ContentQueryParser.cs | 49 +--- .../Schemas/ContentJsonSchemaBuilder.cs | 48 ++++ .../Api/Config/OpenApi/CommonProcessor.cs | 1 + .../Api/Config/OpenApi/ErrorDtoProcessor.cs | 7 +- .../Api/Config/OpenApi/ODataExtensions.cs | 29 --- .../Api/Config/OpenApi/OpenApiServices.cs | 5 +- .../Api/Config/OpenApi/QueryExtensions.cs | 87 +++++++ ...msProcessor.cs => QueryParamsProcessor.cs} | 18 +- .../Api/Config/OpenApi/SecurityProcessor.cs | 2 +- .../Contents/ContentOpenApiController.cs | 27 ++ .../Controllers/Contents/Generator/Builder.cs | 131 ++++++++++ .../Contents/Generator/OperationBuilder.cs | 131 ++++++++++ .../Contents/Generator/OperationsBuilder.cs | 54 ++++ .../Generator/SchemaOpenApiGenerator.cs | 241 ------------------ .../Generator/SchemasOpenApiGenerator.cs | 175 ++++++++++--- .../Docs/{schemabody.md => schema-body.md} | 0 backend/src/Squidex/Docs/schema-query.md | 27 ++ backend/src/Squidex/Docs/schemaquery.md | 11 - backend/src/Squidex/Docs/security.md | 4 +- .../{NSwagHelper.cs => OpenApiHelper.cs} | 48 +--- backend/src/Squidex/Squidex.csproj | 8 +- .../GenerateJsonSchema/JsonSchemaTests.cs | 42 ++- 26 files changed, 799 insertions(+), 532 deletions(-) delete mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/Schemas/ContentJsonSchemaBuilder.cs delete mode 100644 backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs create mode 100644 backend/src/Squidex/Areas/Api/Config/OpenApi/QueryExtensions.cs rename backend/src/Squidex/Areas/Api/Config/OpenApi/{ODataQueryParamsProcessor.cs => QueryParamsProcessor.cs} (54%) create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/Builder.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationBuilder.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationsBuilder.cs delete mode 100644 backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaOpenApiGenerator.cs rename backend/src/Squidex/Docs/{schemabody.md => schema-body.md} (100%) create mode 100644 backend/src/Squidex/Docs/schema-query.md delete mode 100644 backend/src/Squidex/Docs/schemaquery.md rename backend/src/Squidex/Pipeline/OpenApi/{NSwagHelper.cs => OpenApiHelper.cs} (55%) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs deleted file mode 100644 index af00a560e..000000000 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/ContentSchemaBuilder.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using NJsonSchema; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.GenerateJsonSchema -{ - public static class ContentSchemaBuilder - { - public static JsonSchema CreateContentSchema(Schema schema, JsonSchema dataSchema) - { - Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(dataSchema, nameof(dataSchema)); - - var schemaName = schema.Properties.Label.Or(schema.Name); - - var contentSchema = new JsonSchema - { - Properties = - { - ["id"] = SchemaBuilder.StringProperty($"The id of the {schemaName} content.", true), - ["data"] = SchemaBuilder.ObjectProperty(dataSchema, $"The data of the {schemaName}.", true), - ["dataDraft"] = SchemaBuilder.ObjectProperty(dataSchema, $"The draft data of the {schemaName}."), - ["version"] = SchemaBuilder.NumberProperty($"The version of the {schemaName}.", true), - ["created"] = SchemaBuilder.DateTimeProperty($"The date and time when the {schemaName} content has been created.", true), - ["createdBy"] = SchemaBuilder.StringProperty($"The user that has created the {schemaName} content.", true), - ["lastModified"] = SchemaBuilder.DateTimeProperty($"The date and time when the {schemaName} content has been modified last.", true), - ["lastModifiedBy"] = SchemaBuilder.StringProperty($"The user that has updated the {schemaName} content last.", true), - ["newStatus"] = SchemaBuilder.StringProperty("The new status of the content."), - ["status"] = SchemaBuilder.StringProperty("The status of the content.", true) - }, - Type = JsonObjectType.Object - }; - - return contentSchema; - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs index d8dd738c9..88442ef04 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs @@ -14,12 +14,35 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema { public static class JsonSchemaExtensions { + public static JsonSchema BuildFlatJsonSchema(this Schema schema, SchemaResolver schemaResolver, bool withHidden = false) + { + Guard.NotNull(schemaResolver, nameof(schemaResolver)); + + var schemaName = schema.TypeName(); + + var jsonSchema = SchemaBuilder.Object(); + + foreach (var field in schema.Fields.ForApi(withHidden)) + { + var property = JsonTypeVisitor.BuildProperty(field, schemaResolver, withHidden); + + if (property != null) + { + var propertyReference = schemaResolver($"{schemaName}{field.Name.ToPascalCase()}FlatPropertyDto", () => property); + + jsonSchema.Properties.Add(field.Name, CreateProperty(field, propertyReference)); + } + } + + return jsonSchema; + } + public static JsonSchema BuildJsonSchema(this Schema schema, PartitionResolver partitionResolver, SchemaResolver schemaResolver, bool withHidden = false) { Guard.NotNull(schemaResolver, nameof(schemaResolver)); Guard.NotNull(partitionResolver, nameof(partitionResolver)); - var schemaName = schema.Name.ToPascalCase(); + var schemaName = schema.TypeName(); var jsonSchema = SchemaBuilder.Object(); @@ -47,7 +70,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema if (partitionObject.Properties.Count > 0) { - var propertyReference = schemaResolver($"{schemaName}{field.Name.ToPascalCase()}Property", partitionObject); + var propertyReference = schemaResolver($"{schemaName}{field.Name.ToPascalCase()}PropertyDto", () => partitionObject); jsonSchema.Properties.Add(field.Name, CreateProperty(field, propertyReference)); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs index 99a03edc2..26a2df134 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.ObjectModel; using NJsonSchema; using Squidex.Domain.Apps.Core.Schemas; @@ -12,7 +13,7 @@ using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.GenerateJsonSchema { - public delegate JsonSchema SchemaResolver(string name, JsonSchema schema); + public delegate JsonSchema SchemaResolver(string name, Func schema); internal sealed class JsonTypeVisitor : IFieldVisitor { @@ -65,9 +66,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema public JsonSchemaProperty? Visit(IField field, Args args) { - var itemSchema = args.SchemaResolver("AssetItem", SchemaBuilder.String()); - - return SchemaBuilder.ArrayProperty(itemSchema); + return SchemaBuilder.ArrayProperty(SchemaBuilder.String()); } public JsonSchemaProperty? Visit(IField field, Args args) @@ -82,25 +81,28 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema public JsonSchemaProperty? Visit(IField field, Args args) { - var geolocationSchema = SchemaBuilder.Object(); + var reference = args.SchemaResolver("GeolocationDto", () => + { + var geolocationSchema = SchemaBuilder.Object(); - geolocationSchema.Format = GeoJson.Format; + geolocationSchema.Format = GeoJson.Format; - geolocationSchema.Properties.Add("latitude", new JsonSchemaProperty - { - Type = JsonObjectType.Number, - Maximum = 90, - Minimum = -90 - }.SetRequired(true)); + geolocationSchema.Properties.Add("latitude", new JsonSchemaProperty + { + Type = JsonObjectType.Number, + Maximum = 90, + Minimum = -90 + }.SetRequired(true)); - geolocationSchema.Properties.Add("longitude", new JsonSchemaProperty - { - Type = JsonObjectType.Number, - Maximum = 180, - Minimum = -180 - }.SetRequired(true)); + geolocationSchema.Properties.Add("longitude", new JsonSchemaProperty + { + Type = JsonObjectType.Number, + Maximum = 180, + Minimum = -180 + }.SetRequired(true)); - var reference = args.SchemaResolver("GeolocationDto", geolocationSchema); + return geolocationSchema; + }); return SchemaBuilder.ObjectProperty(reference); } @@ -129,9 +131,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema public JsonSchemaProperty? Visit(IField field, Args args) { - var itemSchema = args.SchemaResolver("ReferenceItem", SchemaBuilder.String()); - - return SchemaBuilder.ArrayProperty(itemSchema); + return SchemaBuilder.ArrayProperty(SchemaBuilder.String()); } public JsonSchemaProperty? Visit(IField field, Args args) @@ -140,6 +140,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema property.MaxLength = field.Properties.MaxLength; property.MinLength = field.Properties.MinLength; + property.Pattern = field.Properties.Pattern; if (field.Properties.AllowedValues != null) @@ -157,9 +158,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema public JsonSchemaProperty? Visit(IField field, Args args) { - var itemSchema = args.SchemaResolver("ReferenceItem", SchemaBuilder.String()); - - return SchemaBuilder.ArrayProperty(itemSchema); + return SchemaBuilder.ArrayProperty(SchemaBuilder.String()); } public JsonSchemaProperty? Visit(IField field, Args args) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs index 6f9753af2..1ee7d83ef 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs @@ -152,22 +152,22 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries schema.Properties[name.ToCamelCase()] = property; } - AddProperty(nameof(IAssetEntity.Id), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.Created), JsonObjectType.String, JsonFormatStrings.DateTime); - AddProperty(nameof(IAssetEntity.CreatedBy), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.FileHash), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.FileName), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.FileSize), JsonObjectType.Integer); - AddProperty(nameof(IAssetEntity.FileVersion), JsonObjectType.Integer); - AddProperty(nameof(IAssetEntity.IsProtected), JsonObjectType.Boolean); - AddProperty(nameof(IAssetEntity.LastModified), JsonObjectType.String, JsonFormatStrings.DateTime); - AddProperty(nameof(IAssetEntity.LastModifiedBy), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.Metadata), JsonObjectType.None); - AddProperty(nameof(IAssetEntity.MimeType), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.Slug), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.Tags), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.Type), JsonObjectType.String); - AddProperty(nameof(IAssetEntity.Version), JsonObjectType.Integer); + AddProperty("id", JsonObjectType.String); + AddProperty("version", JsonObjectType.Integer); + AddProperty("created", JsonObjectType.String, JsonFormatStrings.DateTime); + AddProperty("createdBy", JsonObjectType.String); + AddProperty("fileHash", JsonObjectType.String); + AddProperty("fileName", JsonObjectType.String); + AddProperty("fileSize", JsonObjectType.Integer); + AddProperty("fileVersion", JsonObjectType.Integer); + AddProperty("isProtected", JsonObjectType.Boolean); + AddProperty("lastModified", JsonObjectType.String, JsonFormatStrings.DateTime); + AddProperty("lastModifiedBy", JsonObjectType.String); + AddProperty("metadata", JsonObjectType.None); + AddProperty("mimeType", JsonObjectType.String); + AddProperty("slug", JsonObjectType.String); + AddProperty("tags", JsonObjectType.String); + AddProperty("type", JsonObjectType.String); return schema; } @@ -188,23 +188,23 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries var jsonType = new EdmComplexType("Squidex", "Json", null, false, true); - AddPropertyReference(nameof(IAssetEntity.Metadata), new EdmComplexTypeReference(jsonType, false)); - - AddProperty(nameof(IAssetEntity.Id), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.Created), EdmPrimitiveTypeKind.DateTimeOffset); - AddProperty(nameof(IAssetEntity.CreatedBy), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.FileHash), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.FileName), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.IsProtected), EdmPrimitiveTypeKind.Boolean); - AddProperty(nameof(IAssetEntity.FileSize), EdmPrimitiveTypeKind.Int64); - AddProperty(nameof(IAssetEntity.FileVersion), EdmPrimitiveTypeKind.Int64); - AddProperty(nameof(IAssetEntity.LastModified), EdmPrimitiveTypeKind.DateTimeOffset); - AddProperty(nameof(IAssetEntity.LastModifiedBy), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.MimeType), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.Slug), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.Tags), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.Type), EdmPrimitiveTypeKind.String); - AddProperty(nameof(IAssetEntity.Version), EdmPrimitiveTypeKind.Int64); + AddPropertyReference("Metadata", new EdmComplexTypeReference(jsonType, false)); + + AddProperty("id", EdmPrimitiveTypeKind.String); + AddProperty("version", EdmPrimitiveTypeKind.Int64); + AddProperty("created", EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty("createdBy", EdmPrimitiveTypeKind.String); + AddProperty("fileHash", EdmPrimitiveTypeKind.String); + AddProperty("fileName", EdmPrimitiveTypeKind.String); + AddProperty("isProtected", EdmPrimitiveTypeKind.Boolean); + AddProperty("fileSize", EdmPrimitiveTypeKind.Int64); + AddProperty("fileVersion", EdmPrimitiveTypeKind.Int64); + AddProperty("lastModified", EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty("lastModifiedBy", EdmPrimitiveTypeKind.String); + AddProperty("mimeType", EdmPrimitiveTypeKind.String); + AddProperty("slug", EdmPrimitiveTypeKind.String); + AddProperty("tags", EdmPrimitiveTypeKind.String); + AddProperty("type", EdmPrimitiveTypeKind.String); var container = new EdmEntityContainer("Squidex", "Container"); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs index e77e4f178..8b70a3395 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs @@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(60); private readonly EdmModel genericEdmModel = BuildEdmModel("Generic", "Content", new EdmModel(), null); - private readonly JsonSchema genericJsonSchema = BuildJsonSchema("Content", null); + private readonly JsonSchema genericJsonSchema = ContentJsonSchemaBuilder.BuildSchema("Content", null); private readonly IMemoryCache cache; private readonly IJsonSerializer jsonSerializer; private readonly ITextIndex textIndex; @@ -235,36 +235,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries private static JsonSchema BuildJsonSchema(Schema schema, IAppEntity app, bool withHiddenFields) { - var dataSchema = schema.BuildJsonSchema(app.PartitionResolver(), (n, s) => s, withHiddenFields); + var dataSchema = schema.BuildJsonSchema(app.PartitionResolver(), (n, action) => action(), withHiddenFields); - return BuildJsonSchema(schema.DisplayName(), dataSchema); - } - - private static JsonSchema BuildJsonSchema(string name, JsonSchema? dataSchema) - { - var schema = new JsonSchema - { - Properties = - { - [nameof(IContentEntity.Id).ToCamelCase()] = SchemaBuilder.StringProperty($"The id of the {name} content.", true), - [nameof(IContentEntity.Version).ToCamelCase()] = SchemaBuilder.NumberProperty($"The version of the {name}.", true), - [nameof(IContentEntity.Created).ToCamelCase()] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been created.", true), - [nameof(IContentEntity.CreatedBy).ToCamelCase()] = SchemaBuilder.StringProperty($"The user that has created the {name} content.", true), - [nameof(IContentEntity.LastModified).ToCamelCase()] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been modified last.", true), - [nameof(IContentEntity.LastModifiedBy).ToCamelCase()] = SchemaBuilder.StringProperty($"The user that has updated the {name} content last.", true), - [nameof(IContentEntity.NewStatus).ToCamelCase()] = SchemaBuilder.StringProperty("The new status of the content."), - [nameof(IContentEntity.Status).ToCamelCase()] = SchemaBuilder.StringProperty("The status of the content.", true) - }, - Type = JsonObjectType.Object - }; - - if (dataSchema != null) - { - schema.Properties["data"] = SchemaBuilder.ObjectProperty(dataSchema, $"The data of the {name}.", true); - schema.Properties["dataDraft"] = SchemaBuilder.ObjectProperty(dataSchema, $"The draft data of the {name}."); - } - - return schema; + return ContentJsonSchemaBuilder.BuildSchema(schema.DisplayName(), dataSchema); } private static EdmModel BuildEdmModel(Schema schema, IAppEntity app, bool withHiddenFields) @@ -307,14 +280,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { var entityType = new EdmEntityType(modelName, name); - entityType.AddStructuralProperty(nameof(IContentEntity.Id).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IContentEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IContentEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.NewStatus).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.Status).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int32); + entityType.AddStructuralProperty("id", EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty("created", EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty("createdBy", EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty("lastModified", EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty("lastModifiedBy", EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty("newStatus", EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty("status", EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty("version", EdmPrimitiveTypeKind.Int32); if (schemaType != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Schemas/ContentJsonSchemaBuilder.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Schemas/ContentJsonSchemaBuilder.cs new file mode 100644 index 000000000..d10f479ba --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Schemas/ContentJsonSchemaBuilder.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NJsonSchema; + +namespace Squidex.Domain.Apps.Core.GenerateJsonSchema +{ + public static class ContentJsonSchemaBuilder + { + public static JsonSchema BuildSchema(string name, JsonSchema? dataSchema, bool extended = false) + { + var jsonSchema = new JsonSchema + { + Properties = + { + ["id"] = SchemaBuilder.StringProperty($"The id of the {name} content.", true), + ["created"] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been created.", true), + ["createdBy"] = SchemaBuilder.StringProperty($"The user that has created the {name} content.", true), + ["lastModified"] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been modified last.", true), + ["lastModifiedBy"] = SchemaBuilder.StringProperty($"The user that has updated the {name} content last.", true), + ["newStatus"] = SchemaBuilder.StringProperty("The new status of the content."), + ["status"] = SchemaBuilder.StringProperty("The status of the content.", true), + }, + Type = JsonObjectType.Object + }; + + if (extended) + { + jsonSchema.Properties["newStatusColor"] = SchemaBuilder.StringProperty("The color of the new status.", false); + jsonSchema.Properties["schema"] = SchemaBuilder.StringProperty("The name of the schema.", true); + jsonSchema.Properties["SchemaName"] = SchemaBuilder.StringProperty("The display name of the schema.", true); + jsonSchema.Properties["statusColor"] = SchemaBuilder.StringProperty("The color of the status.", true); + } + + if (dataSchema != null) + { + jsonSchema.Properties["data"] = SchemaBuilder.ObjectProperty(dataSchema, $"The data of the {name}.", true); + jsonSchema.Properties["dataDraft"] = SchemaBuilder.ObjectProperty(dataSchema, $"The draft data of the {name}."); + } + + return jsonSchema; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs index a9c3f384b..11ca1ca70 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs @@ -19,6 +19,7 @@ namespace Squidex.Areas.Api.Config.OpenApi private readonly string version; private readonly string backgroundColor = "#3f83df"; private readonly string logoUrl; + private readonly OpenApiExternalDocumentation documentation = new OpenApiExternalDocumentation { Url = "https://docs.squidex.io" diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs index 7e5beb08b..3faa62505 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs @@ -12,7 +12,6 @@ using NJsonSchema; using NSwag; using NSwag.Generation.Processors; using NSwag.Generation.Processors.Contexts; -using Squidex.Pipeline.OpenApi; using Squidex.Web; namespace Squidex.Areas.Api.Config.OpenApi @@ -35,7 +34,11 @@ namespace Squidex.Areas.Api.Config.OpenApi { if (!operation.Responses.ContainsKey("500")) { - operation.AddResponse("500", "Operation failed", errorSchema); + const string description = "Operation failed."; + + var response = new OpenApiResponse { Description = description, Schema = errorSchema }; + + operation.Responses["500"] = response; } foreach (var (code, response) in operation.Responses) diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs deleted file mode 100644 index 16ffd1f19..000000000 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using NJsonSchema; -using NSwag; -using Squidex.Pipeline.OpenApi; - -namespace Squidex.Areas.Api.Config.OpenApi -{ - public static class ODataExtensions - { - public static void AddOData(this OpenApiOperation operation, string entity, bool supportSearch) - { - if (supportSearch) - { - operation.AddQuery("$search", JsonObjectType.String, "Optional OData full text search."); - } - - operation.AddQuery("$top", JsonObjectType.Integer, $"Optional number of {entity} to take."); - operation.AddQuery("$skip", JsonObjectType.Integer, $"Optional number of {entity} to skip."); - operation.AddQuery("$orderby", JsonObjectType.String, "Optional OData order definition."); - operation.AddQuery("$filter", JsonObjectType.String, "Optional OData filter definition."); - } - } -} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs index b0beea647..796f6cb78 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs @@ -12,7 +12,6 @@ using NJsonSchema.Generation.TypeMappers; using NodaTime; using NSwag.Generation; using NSwag.Generation.Processors; -using Squidex.Areas.Api.Controllers.Contents.Generator; using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Contents; @@ -57,10 +56,8 @@ namespace Squidex.Areas.Api.Config.OpenApi settings.ConfigureName(); settings.ConfigureSchemaSettings(); - settings.OperationProcessors.Add(new ODataQueryParamsProcessor("/apps/{app}/assets", "assets", false)); + settings.OperationProcessors.Add(new QueryParamsProcessor("/apps/{app}/assets")); }); - - services.AddTransient(); } private static void ConfigureName(this T settings) where T : OpenApiDocumentGeneratorSettings diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/QueryExtensions.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/QueryExtensions.cs new file mode 100644 index 000000000..18db0e842 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/QueryExtensions.cs @@ -0,0 +1,87 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NJsonSchema; +using NSwag; + +namespace Squidex.Areas.Api.Config.OpenApi +{ + public static class QueryExtensions + { + public static void AddQuery(this OpenApiOperation operation, bool supportSearch) + { + var @string = new JsonSchema + { + Type = JsonObjectType.String + }; + + var number = new JsonSchema + { + Type = JsonObjectType.String + }; + + if (supportSearch) + { + operation.Parameters.Add(new OpenApiParameter + { + Schema = @string, + Name = "$search", + Description = "Optional OData full text search.", + Kind = OpenApiParameterKind.Query + }); + } + + operation.Parameters.Add(new OpenApiParameter + { + Schema = number, + Name = "$top", + Description = "Optional OData parameter to define the number of items to retrieve.", + Kind = OpenApiParameterKind.Query + }); + + operation.Parameters.Add(new OpenApiParameter + { + Schema = number, + Name = "$skip", + Description = "Optional OData parameter to skip items.", + Kind = OpenApiParameterKind.Query + }); + + operation.Parameters.Add(new OpenApiParameter + { + Schema = @string, + Name = "$orderby", + Description = "Optional OData order definition to sort the result set.", + Kind = OpenApiParameterKind.Query + }); + + operation.Parameters.Add(new OpenApiParameter + { + Schema = @string, + Name = "$filter", + Description = "Optional OData order definition to filter the result set.", + Kind = OpenApiParameterKind.Query + }); + + operation.Parameters.Add(new OpenApiParameter + { + Schema = @string, + Name = "q", + Description = "JSON query as well formatted json string. Overrides all other query parameters, except 'ids'.", + Kind = OpenApiParameterKind.Query + }); + + operation.Parameters.Add(new OpenApiParameter + { + Schema = @string, + Name = "ids", + Description = "Comma separated list of content items. Overrides all other query parameters.", + Kind = OpenApiParameterKind.Query + }); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataQueryParamsProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/QueryParamsProcessor.cs similarity index 54% rename from backend/src/Squidex/Areas/Api/Config/OpenApi/ODataQueryParamsProcessor.cs rename to backend/src/Squidex/Areas/Api/Config/OpenApi/QueryParamsProcessor.cs index 5f4bf73d7..1dbb716e2 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataQueryParamsProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/QueryParamsProcessor.cs @@ -5,32 +5,28 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using NSwag; using NSwag.Generation.Processors; using NSwag.Generation.Processors.Contexts; namespace Squidex.Areas.Api.Config.OpenApi { - public sealed class ODataQueryParamsProcessor : IOperationProcessor + public sealed class QueryParamsProcessor : IOperationProcessor { - private readonly string supportedPath; - private readonly string entity; - private readonly bool supportSearch; + private readonly string path; - public ODataQueryParamsProcessor(string supportedPath, string entity, bool supportSearch) + public QueryParamsProcessor(string path) { - this.entity = entity; - - this.supportSearch = supportSearch; - this.supportedPath = supportedPath; + this.path = path; } public bool Process(OperationProcessorContext context) { - if (context.OperationDescription.Path == supportedPath && context.OperationDescription.Method == "get") + if (context.OperationDescription.Path == path && context.OperationDescription.Method == OpenApiOperationMethod.Get) { var operation = context.OperationDescription.Operation; - operation.AddOData(entity, supportSearch); + operation.AddQuery(false); } return true; diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs index 3d5538083..f3c622db4 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs @@ -55,7 +55,7 @@ namespace Squidex.Areas.Api.Config.OpenApi private static void SetupDescription(OpenApiSecurityScheme securityScheme, string tokenUrl) { - var securityText = NSwagHelper.SecurityDocs.Replace("", tokenUrl); + var securityText = OpenApiHelper.SecurityDocs.Replace("", tokenUrl); securityScheme.Description = securityText; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs index ad10b55a9..9d6b18748 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs @@ -42,6 +42,20 @@ namespace Squidex.Areas.Api.Controllers.Contents return View(nameof(Docs), vm); } + [HttpGet] + [Route("content/{app}/docs/flat/")] + [ApiCosts(0)] + [AllowAnonymous] + public IActionResult DocsFlat(string app) + { + var vm = new DocsVM + { + Specification = $"~/content/{app}/flat/swagger/v1/swagger.json" + }; + + return View(nameof(Docs), vm); + } + [HttpGet] [Route("content/{app}/swagger/v1/swagger.json")] [ApiCosts(0)] @@ -54,5 +68,18 @@ namespace Squidex.Areas.Api.Controllers.Contents return Content(openApiDocument.ToJson(), "application/json"); } + + [HttpGet] + [Route("content/{app}/flat/swagger/v1/swagger.json")] + [ApiCosts(0)] + [AllowAnonymous] + public async Task GetFlatOpenApi(string app) + { + var schemas = await appProvider.GetSchemasAsync(AppId); + + var openApiDocument = schemasOpenApiGenerator.Generate(HttpContext, App, schemas, true); + + return Content(openApiDocument.ToJson(), "application/json"); + } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/Builder.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/Builder.cs new file mode 100644 index 000000000..bfa7eff53 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/Builder.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Namotion.Reflection; +using NJsonSchema; +using NSwag; +using NSwag.Generation; +using Squidex.Areas.Api.Controllers.Contents.Models; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.GenerateJsonSchema; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; + +namespace Squidex.Areas.Api.Controllers.Contents.Generator +{ + internal sealed class Builder + { + private readonly PartitionResolver partitionResolver; + + public string AppName { get; } + + public JsonSchema ChangeStatusSchema { get; } + + public OpenApiDocument Document { get; } + + internal Builder(IAppEntity app, + OpenApiDocument document, + OpenApiSchemaResolver schemaResolver, + OpenApiSchemaGenerator schemaGenerator) + { + this.partitionResolver = app.PartitionResolver(); + + Document = document; + + AppName = app.Name; + + ChangeStatusSchema = schemaGenerator.GenerateWithReference(typeof(ChangeStatusDto).ToContextualType(), schemaResolver); + } + + public OperationsBuilder Shared() + { + var dataSchema = ResolveSchema("DataDto", () => + { + return JsonSchema.CreateAnySchema(); + }); + + var contentSchema = ResolveSchema($"ContentDto", () => + { + return ContentJsonSchemaBuilder.BuildSchema("Shared", dataSchema, true); + }); + + var path = $"/content/{AppName}"; + + var builder = new OperationsBuilder + { + ContentSchema = contentSchema, + DataSchema = dataSchema, + Path = path, + Parent = this, + SchemaDisplayName = "__Shared", + SchemaName = "__Shared", + SchemaTypeName = "__Shared", + }; + + var description = "API endpoints for operations across all schemas."; + + Document.Tags.Add(new OpenApiTag { Name = "__Shared", Description = description }); + + return builder; + } + + public OperationsBuilder Schema(Schema schema, bool flat) + { + var typeName = schema.TypeName(); + + var displayName = schema.DisplayName(); + + var dataSchema = ResolveSchema($"{typeName}DataDto", () => + { + return schema.BuildJsonSchema(partitionResolver, ResolveSchema); + }); + + var dataFlatSchema = ResolveSchema($"{typeName}FlatDataDto", () => + { + return schema.BuildFlatJsonSchema(ResolveSchema); + }); + + var contentSchema = ResolveSchema($"{typeName}ContentDto", () => + { + var data = flat ? dataFlatSchema : dataSchema; + + return ContentJsonSchemaBuilder.BuildSchema(displayName, dataFlatSchema, true); + }); + + var path = $"/content/{AppName}/{schema.Name}"; + + var builder = new OperationsBuilder + { + ContentSchema = contentSchema, + DataSchema = dataSchema, + Path = path, + Parent = this, + SchemaDisplayName = displayName, + SchemaName = schema.Name, + SchemaTypeName = typeName + }; + + var description = builder.FormatText("API endpoints for schema content items."); + + Document.Tags.Add(new OpenApiTag { Name = displayName, Description = description }); + + return builder; + } + + private JsonSchema ResolveSchema(string name, Func factory) + { + name = char.ToUpperInvariant(name[0]) + name[1..]; + + return new JsonSchema + { + Reference = Document.Definitions.GetOrAdd(name, x => factory()) + }; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationBuilder.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationBuilder.cs new file mode 100644 index 000000000..43fdd02f3 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationBuilder.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using NJsonSchema; +using NSwag; +using Squidex.Areas.Api.Config.OpenApi; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Contents.Generator +{ + internal sealed class OperationBuilder + { + private readonly OpenApiOperation operation = new OpenApiOperation(); + private readonly OperationsBuilder operations; + + public OperationBuilder(OperationsBuilder operations, OpenApiOperation operation) + { + this.operations = operations; + this.operation = operation; + } + + public OperationBuilder Operation(string name) + { + operation.OperationId = $"{name}{operations.SchemaTypeName}Content"; + + return this; + } + + public OperationBuilder OperationSummary(string summary) + { + if (!string.IsNullOrWhiteSpace(summary)) + { + operation.Summary = operations.FormatText(summary); + } + + return this; + } + + public OperationBuilder Describe(string description) + { + if (!string.IsNullOrWhiteSpace(description)) + { + operation.Description = description; + } + + return this; + } + + public OperationBuilder HasId() + { + HasPath("id", JsonObjectType.String, $"The id of the schema content item."); + + Responds(404, "Content item not found."); + + return this; + } + + private OperationBuilder AddParameter(string name, JsonSchema schema, OpenApiParameterKind kind, string description) + { + var parameter = new OpenApiParameter { Schema = schema, Name = name, Kind = kind }; + + if (!string.IsNullOrWhiteSpace(description)) + { + parameter.Description = operations.FormatText(description); + } + + parameter.IsRequired = kind != OpenApiParameterKind.Query; + + operation.Parameters.Add(parameter); + + return this; + } + + public OperationBuilder HasQueryOptions(bool supportSearch) + { + operation.AddQuery(true); + + return this; + } + + public OperationBuilder HasQuery(string name, JsonObjectType type, string description) + { + var jsonSchema = new JsonSchema { Type = type }; + + return AddParameter(name, jsonSchema, OpenApiParameterKind.Query, description); + } + + public OperationBuilder HasPath(string name, JsonObjectType type, string description, string? format = null) + { + var jsonSchema = new JsonSchema { Type = type, Format = format }; + + return AddParameter(name, jsonSchema, OpenApiParameterKind.Path, description); + } + + public OperationBuilder HasBody(string name, JsonSchema jsonSchema, string description) + { + return AddParameter(name, jsonSchema, OpenApiParameterKind.Body, description); + } + + public OperationBuilder Responds(int statusCode, string description, JsonSchema? schema = null) + { + var response = new OpenApiResponse { Description = description, Schema = schema }; + + operation.Responses.Add(statusCode.ToString(), response); + + return this; + } + + public OperationBuilder RequirePermission(string permissionId) + { + operation.Security = new List + { + new OpenApiSecurityRequirement + { + [Constants.SecurityDefinition] = new[] + { + Permissions.ForApp(permissionId, operations.Parent.AppName, operations.SchemaName).Id + } + } + }; + + return this; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationsBuilder.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationsBuilder.cs new file mode 100644 index 000000000..524f71237 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationsBuilder.cs @@ -0,0 +1,54 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using NJsonSchema; +using NSwag; +using Squidex.Infrastructure; + +namespace Squidex.Areas.Api.Controllers.Contents.Generator +{ + internal sealed class OperationsBuilder + { + public Builder Parent { get; init; } + + public string Path { get; init; } + + public string SchemaName { get; init; } + + public string SchemaTypeName { get; init; } + + public string SchemaDisplayName { get; init; } + + public JsonSchema ContentSchema { get; init; } + + public JsonSchema DataSchema { get; init; } + + public string FormatText(string text) + { + return text?.Replace("schema ", $"'{SchemaDisplayName}' ", StringComparison.OrdinalIgnoreCase)!; + } + + public OperationBuilder AddOperation(string method, string path) + { + var operation = new OpenApiOperation + { + Tags = new List + { + SchemaDisplayName + } + }; + + var operations = Parent.Document.Paths.GetOrAddNew($"{Path}{path}"); + + operations[method] = operation; + + return new OperationBuilder(this, operation); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaOpenApiGenerator.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaOpenApiGenerator.cs deleted file mode 100644 index 6c6f15dfa..000000000 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaOpenApiGenerator.cs +++ /dev/null @@ -1,241 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using NJsonSchema; -using NSwag; -using Squidex.Areas.Api.Config.OpenApi; -using Squidex.Domain.Apps.Core; -using Squidex.Domain.Apps.Core.GenerateJsonSchema; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Pipeline.OpenApi; -using Squidex.Shared; -using Squidex.Web; - -namespace Squidex.Areas.Api.Controllers.Contents.Generator -{ - public sealed class SchemaOpenApiGenerator - { - private readonly OpenApiDocument document; - private readonly JsonSchema contentSchema; - private readonly JsonSchema dataSchema; - private readonly string schemaPath; - private readonly string schemaName; - private readonly string schemaType; - private readonly string appPath; - private readonly JsonSchema statusSchema; - private readonly string appName; - - public SchemaOpenApiGenerator( - OpenApiDocument document, - string appName, - string appPath, - Schema schema, - SchemaResolver schemaResolver, - JsonSchema statusSchema, - PartitionResolver partitionResolver) - { - this.document = document; - - this.appName = appName; - this.appPath = appPath; - - this.statusSchema = statusSchema; - - schemaPath = schema.Name; - schemaName = schema.DisplayName(); - schemaType = schema.TypeName(); - - dataSchema = schemaResolver($"{schemaType}Dto", schema.BuildJsonSchema(partitionResolver, schemaResolver)); - - contentSchema = schemaResolver($"{schemaType}ContentDto", ContentSchemaBuilder.CreateContentSchema(schema, dataSchema)); - } - - public void GenerateSchemaOperations() - { - document.Tags.Add( - new OpenApiTag - { - Name = schemaName, Description = $"API to manage {schemaName} contents." - }); - - var schemaOperations = new[] - { - GenerateSchemaGetsOperation(), - GenerateSchemaGetOperation(), - GenerateSchemaCreateOperation(), - GenerateSchemaUpdateOperation(), - GenerateSchemaUpdatePatchOperation(), - GenerateSchemaStatusOperation(), - GenerateSchemaDeleteOperation() - }; - - foreach (var operation in schemaOperations.SelectMany(x => x.Values).Distinct()) - { - operation.Tags = new List { schemaName }; - } - } - - private OpenApiPathItem GenerateSchemaGetsOperation() - { - return Add(OpenApiOperationMethod.Get, Permissions.AppContentsReadOwn, "/", - operation => - { - operation.OperationId = $"Query{schemaType}Contents"; - - operation.Summary = $"Queries {schemaName} contents."; - - operation.Description = NSwagHelper.SchemaQueryDocs; - - operation.AddOData("contents", true); - - operation.AddResponse("200", $"{schemaName} contents retrieved.", CreateContentsSchema(schemaName, contentSchema)); - operation.AddResponse("400", $"{schemaName} query not valid."); - }); - } - - private OpenApiPathItem GenerateSchemaGetOperation() - { - return Add(OpenApiOperationMethod.Get, Permissions.AppContentsReadOwn, "/{id}", operation => - { - operation.OperationId = $"Get{schemaType}Content"; - - operation.Summary = $"Get a {schemaName} content."; - - operation.AddResponse("200", $"{schemaName} content found.", contentSchema); - }); - } - - private OpenApiPathItem GenerateSchemaCreateOperation() - { - return Add(OpenApiOperationMethod.Post, Permissions.AppContentsCreate, "/", - operation => - { - operation.OperationId = $"Create{schemaType}Content"; - - operation.Summary = $"Create a {schemaName} content."; - - operation.AddBody("data", dataSchema, NSwagHelper.SchemaBodyDocs); - operation.AddQuery("publish", JsonObjectType.Boolean, "True to automatically publish the content."); - operation.AddQuery("id", JsonObjectType.String, "The optional custom content id."); - - operation.AddResponse("201", $"{schemaName} content created.", contentSchema); - operation.AddResponse("400", $"{schemaName} content not valid."); - }); - } - - private OpenApiPathItem GenerateSchemaUpdateOperation() - { - return Add(OpenApiOperationMethod.Put, Permissions.AppContentsUpdateOwn, "/{id}", - operation => - { - operation.OperationId = $"Update{schemaType}Content"; - - operation.Summary = $"Update a {schemaName} content."; - - operation.AddBody("data", dataSchema, NSwagHelper.SchemaBodyDocs); - - operation.AddResponse("200", $"{schemaName} content updated.", contentSchema); - operation.AddResponse("400", $"{schemaName} content not valid."); - }); - } - - private OpenApiPathItem GenerateSchemaUpdatePatchOperation() - { - return Add(OpenApiOperationMethod.Patch, Permissions.AppContentsUpdateOwn, "/{id}", - operation => - { - operation.OperationId = $"Path{schemaType}Content"; - - operation.Summary = $"Patch a {schemaName} content."; - - operation.AddBody("data", dataSchema, NSwagHelper.SchemaBodyDocs); - - operation.AddResponse("200", $"{schemaName} content patched.", contentSchema); - operation.AddResponse("400", $"{schemaName} status not valid."); - }); - } - - private OpenApiPathItem GenerateSchemaStatusOperation() - { - return Add(OpenApiOperationMethod.Put, Permissions.AppContentsUpdateOwn, "/{id}/status", - operation => - { - operation.OperationId = $"Change{schemaType}ContentStatus"; - - operation.Summary = $"Change status of {schemaName} content."; - - operation.AddBody("request", statusSchema, "The request to change content status."); - - operation.AddResponse("200", $"{schemaName} content status changed.", contentSchema); - operation.AddResponse("400", $"{schemaName} content not valid."); - }); - } - - private OpenApiPathItem GenerateSchemaDeleteOperation() - { - return Add(OpenApiOperationMethod.Delete, Permissions.AppContentsDeleteOwn, "/{id}", - operation => - { - operation.OperationId = $"Delete{schemaType}Content"; - - operation.Summary = $"Delete a {schemaName} content."; - - operation.AddResponse("204", $"{schemaName} content deleted."); - }); - } - - private OpenApiPathItem Add(string method, string permission, string path, Action updater) - { - var operations = document.Paths.GetOrAddNew($"{appPath}/{schemaPath}{path}"); - var operation = new OpenApiOperation - { - Security = new List - { - new OpenApiSecurityRequirement - { - [Constants.SecurityDefinition] = new[] - { - Permissions.ForApp(permission, appName, schemaPath).Id - } - } - } - }; - - updater(operation); - - operations[method] = operation; - - if (path.StartsWith("/{id}", StringComparison.OrdinalIgnoreCase)) - { - operation.AddPathParameter("id", JsonObjectType.String, $"The id of the {schemaName} content."); - - operation.AddResponse("404", $"App, schema or {schemaName} content not found."); - } - - return operations; - } - - private static JsonSchema CreateContentsSchema(string schemaName, JsonSchema contentSchema) - { - var schema = new JsonSchema - { - Properties = - { - ["total"] = SchemaBuilder.NumberProperty($"The total number of {schemaName} contents.", true), - ["items"] = SchemaBuilder.ArrayProperty(contentSchema, $"The {schemaName} contents.", true) - }, - Type = JsonObjectType.Object - }; - - return schema; - } - } -} \ No newline at end of file diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs index 86418bfc2..8c7c51d4a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs @@ -9,31 +9,26 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Http; -using Namotion.Reflection; using NJsonSchema; -using NJsonSchema.Generation; using NSwag; using NSwag.Generation; using NSwag.Generation.Processors; using NSwag.Generation.Processors.Contexts; using Squidex.Areas.Api.Config.OpenApi; -using Squidex.Areas.Api.Controllers.Contents.Models; +using Squidex.Domain.Apps.Core.GenerateJsonSchema; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Pipeline.OpenApi; +using Squidex.Shared; namespace Squidex.Areas.Api.Controllers.Contents.Generator { public sealed class SchemasOpenApiGenerator { private readonly OpenApiDocumentGeneratorSettings settings = new OpenApiDocumentGeneratorSettings(); + private readonly OpenApiSchemaGenerator schemaGenerator; private readonly IRequestCache requestCache; - private OpenApiSchemaGenerator schemaGenerator; - private OpenApiDocument document; - private JsonSchema statusSchema; - private JsonSchemaResolver schemaResolver; public SchemasOpenApiGenerator(IEnumerable documentProcessors, IRequestCache requestCache) { @@ -44,19 +39,33 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator settings.DocumentProcessors.Add(processor); } + schemaGenerator = new OpenApiSchemaGenerator(settings); + this.requestCache = requestCache; } - public OpenApiDocument Generate(HttpContext httpContext, IAppEntity app, IEnumerable schemas) + public OpenApiDocument Generate(HttpContext httpContext, IAppEntity app, IEnumerable schemas, bool flat = false) { - document = NSwagHelper.CreateApiDocument(httpContext, app.Name); + var document = OpenApiHelper.CreateApiDocument(httpContext, app.Name); - schemaGenerator = new OpenApiSchemaGenerator(settings); - schemaResolver = new OpenApiSchemaResolver(document, settings); + var schemaResolver = new OpenApiSchemaResolver(document, settings); + + requestCache.AddDependency(app.UniqueId, app.Version); + + var builder = new Builder( + app, + document, + schemaResolver, + schemaGenerator); - statusSchema = GenerateStatusSchema(); + foreach (var schema in schemas.Where(x => x.SchemaDef.IsPublished)) + { + requestCache.AddDependency(schema.UniqueId, schema.Version); - GenerateSchemasOperations(schemas, app); + GenerateSchemaOperations(builder.Schema(schema.SchemaDef, flat)); + } + + GenerateSharedOperations(builder.Shared()); var context = new DocumentProcessorContext(document, @@ -74,35 +83,129 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator return document; } - private JsonSchema GenerateStatusSchema() + private static void GenerateSharedOperations(OperationsBuilder builder) { - var statusDtoType = typeof(ChangeStatusDto); - - return schemaGenerator.GenerateWithReference(statusDtoType.ToContextualType(), schemaResolver); + var contentsSchema = BuildResults(builder); + + builder.AddOperation(OpenApiOperationMethod.Get, "/") + .RequirePermission(Permissions.AppContentsReadOwn) + .Operation("Query") + .OperationSummary("Query contents across all schemas.") + .HasQuery("ids", JsonObjectType.String, "Comma-separated list of content IDs.") + .Responds(200, "Content items retrieved.", contentsSchema) + .Responds(400, "Query not valid."); } - private void GenerateSchemasOperations(IEnumerable schemas, IAppEntity app) + private static void GenerateSchemaOperations(OperationsBuilder builder) { - requestCache.AddDependency(app.UniqueId, app.Version); - - var appBasePath = $"/content/{app.Name}"; - - foreach (var schema in schemas.Where(x => x.SchemaDef.IsPublished)) - { - requestCache.AddDependency(schema.UniqueId, schema.Version); - - var partition = app.PartitionResolver(); - - new SchemaOpenApiGenerator(document, app.Name, appBasePath, schema.SchemaDef, AppendSchema, statusSchema, partition) - .GenerateSchemaOperations(); - } + var contentsSchema = BuildResults(builder); + + builder.AddOperation(OpenApiOperationMethod.Get, "/") + .RequirePermission(Permissions.AppContentsReadOwn) + .Operation("Query") + .OperationSummary("Query schema contents items.") + .Describe(OpenApiHelper.SchemaQueryDocs) + .HasQueryOptions(true) + .Responds(200, "Content items retrieved.", contentsSchema) + .Responds(400, "Query not valid."); + + builder.AddOperation(OpenApiOperationMethod.Get, "/{id}") + .RequirePermission(Permissions.AppContentsReadOwn) + .Operation("Get") + .OperationSummary("Get a schema content item.") + .HasId() + .Responds(200, "Content item returned.", builder.ContentSchema); + + builder.AddOperation(OpenApiOperationMethod.Get, "/{id}/{version}") + .RequirePermission(Permissions.AppContentsReadOwn) + .Operation("Get") + .OperationSummary("Get a schema content item by id and version.") + .HasPath("version", JsonObjectType.Number, "The version of the content item.") + .HasId() + .Responds(200, "Content item returned.", builder.ContentSchema); + + builder.AddOperation(OpenApiOperationMethod.Get, "/{id}/validity") + .RequirePermission(Permissions.AppContentsReadOwn) + .Operation("Validate") + .OperationSummary("Validates a schema content item.") + .HasId() + .Responds(200, "Content item is valid.") + .Responds(400, "Content item is not valid."); + + builder.AddOperation(OpenApiOperationMethod.Post, "/") + .RequirePermission(Permissions.AppContentsCreate) + .Operation("Create") + .OperationSummary("Create a schema content item.") + .HasQuery("publish", JsonObjectType.Boolean, "True to automatically publish the content.") + .HasQuery("id", JsonObjectType.String, "The optional custom content id.") + .HasBody("data", builder.DataSchema, OpenApiHelper.SchemaBodyDocs) + .Responds(201, "Content item created", builder.ContentSchema) + .Responds(400, "Content data not valid."); + + builder.AddOperation(OpenApiOperationMethod.Post, "/{id}") + .RequirePermission(Permissions.AppContentsUpsert) + .Operation("Upsert") + .OperationSummary("Upsert a schema content item.") + .HasQuery("publish", JsonObjectType.Boolean, "True to automatically publish the content.") + .HasId() + .HasBody("data", builder.DataSchema, OpenApiHelper.SchemaBodyDocs) + .Responds(200, "Content item created or updated.", builder.ContentSchema) + .Responds(400, "Content data not valid."); + + builder.AddOperation(OpenApiOperationMethod.Put, "/{id}") + .RequirePermission(Permissions.AppContentsUpdateOwn) + .Operation("Update") + .OperationSummary("Update a schema content item.") + .HasId() + .HasBody("data", builder.DataSchema, OpenApiHelper.SchemaBodyDocs) + .Responds(200, "Content item updated.", builder.ContentSchema) + .Responds(400, "Content data not valid."); + + builder.AddOperation(OpenApiOperationMethod.Patch, "/{id}") + .RequirePermission(Permissions.AppContentsUpdateOwn) + .Operation("Patch") + .OperationSummary("Patch a schema content item.") + .HasId() + .HasBody("data", builder.DataSchema, OpenApiHelper.SchemaBodyDocs) + .Responds(200, "Content item updated.", builder.ContentSchema) + .Responds(400, "Content data not valid."); + + builder.AddOperation(OpenApiOperationMethod.Put, "/{id}/status") + .RequirePermission(Permissions.AppContentsUpdateOwn) + .Operation("Patch") + .OperationSummary("Patch a schema content item.") + .HasId() + .HasBody("data", builder.DataSchema, OpenApiHelper.SchemaBodyDocs) + .Responds(200, "Content item updated.", builder.ContentSchema) + .Responds(400, "Content data not valid."); + + builder.AddOperation(OpenApiOperationMethod.Delete, "/{id}") + .RequirePermission(Permissions.AppContentsChangeStatusOwn) + .Operation("Change") + .OperationSummary("Change the status of a schema content item.") + .HasId() + .HasBody("request", builder.Parent.ChangeStatusSchema, "The request to change content status.") + .Responds(200, "Content status updated.", builder.ContentSchema); + + builder.AddOperation(OpenApiOperationMethod.Delete, "/{id}") + .RequirePermission(Permissions.AppContentsDeleteOwn) + .Operation("Delete") + .OperationSummary("Delete a schema content item.") + .HasId() + .Responds(204, "Content item deleted"); } - private JsonSchema AppendSchema(string name, JsonSchema schema) + private static JsonSchema BuildResults(OperationsBuilder builder) { - name = char.ToUpperInvariant(name[0]) + name[1..]; - - return new JsonSchema { Reference = document.Definitions.GetOrAdd(name, schema, (k, c) => c) }; + return new JsonSchema + { + Properties = + { + ["total"] = SchemaBuilder.NumberProperty("The total number of content items.", true), + ["items"] = SchemaBuilder.ArrayProperty(builder.ContentSchema, "The content items.", true) + }, + Type = JsonObjectType.Object + }; } } } diff --git a/backend/src/Squidex/Docs/schemabody.md b/backend/src/Squidex/Docs/schema-body.md similarity index 100% rename from backend/src/Squidex/Docs/schemabody.md rename to backend/src/Squidex/Docs/schema-body.md diff --git a/backend/src/Squidex/Docs/schema-query.md b/backend/src/Squidex/Docs/schema-query.md new file mode 100644 index 000000000..87cc75e27 --- /dev/null +++ b/backend/src/Squidex/Docs/schema-query.md @@ -0,0 +1,27 @@ +How to make queries? + +Read more about it at: https://docs.squidex.io/04-guides/02-api.html + +The query endpoints support three options: + +### Query with OData + +Squidex supports a subset of the OData (https://www.odata.org/) syntax with with the following query options: + +* **$top**: The $top query option requests the number of items in the queried collection to be included in the result. The default value is 20 and the maximum allowed value is 200. You can change the maximum in the app settings, when you host Squidex yourself. +* **$skip**: The $skip query option requests the number of items in the queried collection that are to be skipped and not included in the result. Use it together with $top to read the all your data page by page. +* **$search**: The $search query option allows clients to request entities matching a free-text search expression. We add the data of all fields for all languages to our full text engine. +* **$filter**: The $filter query option allows clients to filter a collection of resources that are addressed by a request URL. +* **$orderby**: The $orderby query option allows clients to request resources in a particular order. + +### Query with JSON query + +Squidex also supports a query syntax based on JSON. You have to pass in the query object as query parameter: + +* **q**: A json text that represents the same query options as with OData, but is more performant to parse. + +### Query by IDs + +Query your items by passing in one or many IDs with the following query parameter: + +* **ids**: A comma-separated list of ids. If you define this option all other settings are ignored. diff --git a/backend/src/Squidex/Docs/schemaquery.md b/backend/src/Squidex/Docs/schemaquery.md deleted file mode 100644 index 1642bd400..000000000 --- a/backend/src/Squidex/Docs/schemaquery.md +++ /dev/null @@ -1,11 +0,0 @@ -The squidex API the OData url convention to query data. - -We support the following query options. - -* **$top**: The $top query option requests the number of items in the queried collection to be included in the result. The default value is 20 and the maximum allowed value is 200. -* **$skip**: The $skip query option requests the number of items in the queried collection that are to be skipped and not included in the result. Use it together with $top to read the all your data page by page. -* **$search**: The $search query option allows clients to request entities matching a free-text search expression. We add the data of all fields for all languages to a single field in the database and use this combined field to implement the full text search. -* **$filter**: The $filter query option allows clients to filter a collection of resources that are addressed by a request URL. -* **$orderby**: The $orderby query option allows clients to request resources in a particular order. - -Read more about it at: https://docs.squidex.io/04-guides/02-api.html diff --git a/backend/src/Squidex/Docs/security.md b/backend/src/Squidex/Docs/security.md index 29e341c57..b58a51275 100644 --- a/backend/src/Squidex/Docs/security.md +++ b/backend/src/Squidex/Docs/security.md @@ -6,12 +6,10 @@ To retrieve an access token, the client id must make a request to the token url. -X POST '' -H 'Content-Type: application/x-www-form-urlencoded' -d 'grant_type=client_credentials& - client_id=[APP_NAME]:[CLIENT_ID]& + client_id=[CLIENT_ID]& client_secret=[CLIENT_SECRET]& scope=squidex-api' -`[APP_NAME]` is the name of your app. You have to create a client to generate an access token. - You must send this token in the `Authorization` header when making requests to the API: Authorization: Bearer \ No newline at end of file diff --git a/backend/src/Squidex/Pipeline/OpenApi/NSwagHelper.cs b/backend/src/Squidex/Pipeline/OpenApi/OpenApiHelper.cs similarity index 55% rename from backend/src/Squidex/Pipeline/OpenApi/NSwagHelper.cs rename to backend/src/Squidex/Pipeline/OpenApi/OpenApiHelper.cs index 140bb62d1..ddf4ab0c0 100644 --- a/backend/src/Squidex/Pipeline/OpenApi/NSwagHelper.cs +++ b/backend/src/Squidex/Pipeline/OpenApi/OpenApiHelper.cs @@ -14,17 +14,17 @@ using NSwag; namespace Squidex.Pipeline.OpenApi { - public static class NSwagHelper + public static class OpenApiHelper { public static readonly string SecurityDocs = LoadDocs("security"); - public static readonly string SchemaBodyDocs = LoadDocs("schemabody"); + public static readonly string SchemaBodyDocs = LoadDocs("schema-body"); - public static readonly string SchemaQueryDocs = LoadDocs("schemaquery"); + public static readonly string SchemaQueryDocs = LoadDocs("schema-query"); private static string LoadDocs(string name) { - var assembly = typeof(NSwagHelper).Assembly; + var assembly = typeof(OpenApiHelper).Assembly; using (var resourceStream = assembly.GetManifestResourceStream($"Squidex.Docs.{name}.md")) { @@ -70,45 +70,5 @@ namespace Squidex.Pipeline.OpenApi return document; } - - public static void AddQuery(this OpenApiOperation operation, string name, JsonObjectType type, string description) - { - var schema = new JsonSchema { Type = type }; - - operation.AddParameter(name, schema, OpenApiParameterKind.Query, description, false); - } - - public static void AddPathParameter(this OpenApiOperation operation, string name, JsonObjectType type, string description, string? format = null) - { - var schema = new JsonSchema { Type = type, Format = format }; - - operation.AddParameter(name, schema, OpenApiParameterKind.Path, description, true); - } - - public static void AddBody(this OpenApiOperation operation, string name, JsonSchema schema, string description) - { - operation.AddParameter(name, schema, OpenApiParameterKind.Body, description, true); - } - - private static void AddParameter(this OpenApiOperation operation, string name, JsonSchema schema, OpenApiParameterKind kind, string description, bool isRequired) - { - var parameter = new OpenApiParameter { Schema = schema, Name = name, Kind = kind }; - - if (!string.IsNullOrWhiteSpace(description)) - { - parameter.Description = description; - } - - parameter.IsRequired = isRequired; - - operation.Parameters.Add(parameter); - } - - public static void AddResponse(this OpenApiOperation operation, string statusCode, string description, JsonSchema? schema = null) - { - var response = new OpenApiResponse { Description = description, Schema = schema }; - - operation.Responses.Add(statusCode, response); - } } } diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index cee1f48af..de4ba5c2e 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -93,8 +93,8 @@ - - + + @@ -109,8 +109,8 @@ - - + + diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/GenerateJsonSchema/JsonSchemaTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/GenerateJsonSchema/JsonSchemaTests.cs index 91ffad592..50939a266 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/GenerateJsonSchema/JsonSchemaTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/GenerateJsonSchema/JsonSchemaTests.cs @@ -25,7 +25,12 @@ namespace Squidex.Domain.Apps.Core.Operations.GenerateJsonSchema { var languagesConfig = LanguagesConfig.English.Set(Language.DE); - var jsonSchema = schema.BuildJsonSchema(languagesConfig.ToResolver(), (n, s) => new JsonSchema { Reference = s }); + var schemaResolver = new SchemaResolver((name, action) => + { + return action(); + }); + + var jsonSchema = schema.BuildJsonSchema(languagesConfig.ToResolver(), schemaResolver); var jsonProperties = AllPropertyNames(jsonSchema); void CheckField(IField field) @@ -55,13 +60,42 @@ namespace Squidex.Domain.Apps.Core.Operations.GenerateJsonSchema } [Fact] - public void Should_build_data_schema() + public void Should_build_flat_json_schema() { var languagesConfig = LanguagesConfig.English.Set(Language.DE); - var jsonSchema = schema.BuildJsonSchema(languagesConfig.ToResolver(), (n, s) => new JsonSchema { Reference = s }); + var schemaResolver = new SchemaResolver((name, action) => + { + return action(); + }); + + var jsonSchema = schema.BuildFlatJsonSchema(schemaResolver); + var jsonProperties = AllPropertyNames(jsonSchema); - Assert.NotNull(jsonSchema); + void CheckField(IField field) + { + if (!field.IsForApi()) + { + Assert.DoesNotContain(field.Name, jsonProperties); + } + else + { + Assert.Contains(field.Name, jsonProperties); + } + + if (field is IArrayField array) + { + foreach (var nested in array.Fields) + { + CheckField(nested); + } + } + } + + foreach (var field in schema.Fields) + { + CheckField(field); + } } private static HashSet AllPropertyNames(JsonSchema schema) From 8cc0517f1113df08560295a79078460f83d138d9 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 6 Feb 2021 23:31:09 +0100 Subject: [PATCH 2/4] Update development-task-or-bug.md --- .github/ISSUE_TEMPLATE/development-task-or-bug.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/development-task-or-bug.md b/.github/ISSUE_TEMPLATE/development-task-or-bug.md index 254609366..93d87bcc2 100644 --- a/.github/ISSUE_TEMPLATE/development-task-or-bug.md +++ b/.github/ISSUE_TEMPLATE/development-task-or-bug.md @@ -9,7 +9,6 @@ assignees: '' Do NOT USE issues for -* Bug Reports * Hosting Questions * Feature Requests * anything that is not related to development. From 2f17ee3b196d87b3363b60a90585b4f8eaa00df3 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 6 Feb 2021 23:31:27 +0100 Subject: [PATCH 3/4] Update development-task-or-bug.md --- .github/ISSUE_TEMPLATE/development-task-or-bug.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/development-task-or-bug.md b/.github/ISSUE_TEMPLATE/development-task-or-bug.md index 93d87bcc2..fa4bd67ce 100644 --- a/.github/ISSUE_TEMPLATE/development-task-or-bug.md +++ b/.github/ISSUE_TEMPLATE/development-task-or-bug.md @@ -9,6 +9,7 @@ assignees: '' Do NOT USE issues for +* Bug reports * Hosting Questions * Feature Requests * anything that is not related to development. From 004ec76b9f5a057dca8641523b253979aaeb3a4e Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 7 Feb 2021 01:00:00 +0100 Subject: [PATCH 4/4] #639 Create JSON schemas for all rule events (#645) --- .../AzureQueue/AzureQueueActionHandler.cs | 3 +- .../Actions/Webhook/WebhookActionHandler.cs | 3 +- .../EnrichedEvents/EnrichedCommentEvent.cs | 13 ++++ .../EnrichedEvents/EnrichedUserEventBase.cs | 7 ++ .../HandleRules/EventJsonSchemaGenerator.cs | 64 +++++++++++++++++ .../Api/Config/OpenApi/OpenApiServices.cs | 24 ++++++- .../Api/Controllers/Rules/RulesController.cs | 47 ++++++++++++- .../src/Squidex/Config/Domain/RuleServices.cs | 3 + .../EventJsonSchemaGeneratorTests.cs | 70 +++++++++++++++++++ 9 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventJsonSchemaGeneratorTests.cs diff --git a/backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs index 8466de2f9..0fdf3f892 100644 --- a/backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs @@ -34,9 +34,10 @@ namespace Squidex.Extensions.Actions.AzureQueue protected override async Task<(string Description, AzureQueueJob Data)> CreateJobAsync(EnrichedEvent @event, AzureQueueAction action) { - var requestBody = string.Empty; var queueName = await FormatAsync(action.Queue, @event); + string requestBody; + if (!string.IsNullOrEmpty(action.Payload)) { requestBody = await FormatAsync(action.Payload, @event); diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs index 6be4938bd..d7d941c4d 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs @@ -30,6 +30,7 @@ namespace Squidex.Extensions.Actions.Webhook protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action) { + var requestUrl = await FormatAsync(action.Url, @event); var requestBody = string.Empty; var requestSignature = string.Empty; @@ -47,8 +48,6 @@ namespace Squidex.Extensions.Actions.Webhook requestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(); } - var requestUrl = await FormatAsync(action.Url, @event); - var ruleDescription = $"Send event to webhook '{requestUrl}'"; var ruleJob = new WebhookJob { diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedCommentEvent.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedCommentEvent.cs index 81b317ee0..71b03f164 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedCommentEvent.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedCommentEvent.cs @@ -9,6 +9,8 @@ using System; using System.Runtime.Serialization; using Squidex.Shared.Users; +#pragma warning disable CA1822 // Mark members as static + namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents { public sealed class EnrichedCommentEvent : EnrichedUserEventBase @@ -20,9 +22,20 @@ namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents [IgnoreDataMember] public IUser MentionedUser { get; set; } + [IgnoreDataMember] public override long Partition { get { return MentionedUser.Id.GetHashCode(); } } + + public bool ShouldSerializeMentionedUser() + { + return false; + } + + public bool ShouldSerializePartition() + { + return false; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedUserEventBase.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedUserEventBase.cs index 6a57cc350..3acc50943 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedUserEventBase.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedUserEventBase.cs @@ -9,6 +9,8 @@ using System.Runtime.Serialization; using Squidex.Infrastructure; using Squidex.Shared.Users; +#pragma warning disable CA1822 // Mark members as static + namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents { public abstract class EnrichedUserEventBase : EnrichedEvent @@ -17,5 +19,10 @@ namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents [IgnoreDataMember] public IUser? User { get; set; } + + public bool ShouldSerializeUser() + { + return false; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs new file mode 100644 index 000000000..1e5db2d49 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventJsonSchemaGenerator.cs @@ -0,0 +1,64 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using NJsonSchema; +using NJsonSchema.Generation; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public sealed class EventJsonSchemaGenerator + { + private readonly Lazy> schemas; + private readonly JsonSchemaGenerator schemaGenerator; + + public IReadOnlyCollection AllTypes + { + get { return schemas.Value.Keys; } + } + + public EventJsonSchemaGenerator(JsonSchemaGenerator schemaGenerator) + { + Guard.NotNull(schemaGenerator, nameof(schemaGenerator)); + + this.schemaGenerator = schemaGenerator; + + schemas = new Lazy>(GenerateSchemas); + } + + public JsonSchema? GetSchema(string typeName) + { + Guard.NotNull(typeName, nameof(typeName)); + + return schemas.Value.GetOrDefault(typeName); + } + + private Dictionary GenerateSchemas() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var baseType = typeof(EnrichedEvent); + + var assembly = baseType.Assembly; + + foreach (var type in assembly.GetTypes()) + { + if (!type.IsAbstract && type.IsAssignableTo(baseType)) + { + var schema = schemaGenerator.Generate(type); + + result[type.Name] = schema!; + } + } + + return result; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs index 796f6cb78..2dbe5e813 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs @@ -5,9 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; using NJsonSchema; +using NJsonSchema.Generation; using NJsonSchema.Generation.TypeMappers; using NodaTime; using NSwag.Generation; @@ -51,6 +54,18 @@ namespace Squidex.Areas.Api.Config.OpenApi services.AddSingletonAs() .As(); + services.AddSingleton(c => + { + var settings = ConfigureSchemaSettings(new JsonSchemaGeneratorSettings + { + FlattenInheritanceHierarchy = true, + SerializerOptions = null, + SerializerSettings = c.GetRequiredService() + }); + + return new JsonSchemaGenerator(settings); + }); + services.AddOpenApiDocument(settings => { settings.ConfigureName(); @@ -65,21 +80,26 @@ namespace Squidex.Areas.Api.Config.OpenApi settings.Title = "Squidex API"; } - public static void ConfigureSchemaSettings(this T settings) where T : OpenApiDocumentGeneratorSettings + public static T ConfigureSchemaSettings(this T settings) where T : JsonSchemaGeneratorSettings { settings.AllowReferencesWithProperties = true; settings.TypeMappers = new List { + CreateStringMap(), CreateStringMap(JsonFormatStrings.DateTime), CreateStringMap(), - CreateStringMap(), + CreateStringMap>(), + CreateStringMap>(), + CreateStringMap>(), CreateStringMap(), CreateStringMap(), CreateObjectMap(), CreateObjectMap() }; + + return settings; } private static ITypeMapper CreateObjectMap() diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index 0138d4aca..da50ee187 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; @@ -34,19 +35,22 @@ namespace Squidex.Areas.Api.Controllers.Rules private readonly IRuleQueryService ruleQuery; private readonly IRuleRunnerService ruleRunnerService; private readonly IRuleEventRepository ruleEventsRepository; + private readonly EventJsonSchemaGenerator eventJsonSchemaGenerator; private readonly RuleRegistry ruleRegistry; public RulesController(ICommandBus commandBus, IRuleEventRepository ruleEventsRepository, IRuleQueryService ruleQuery, IRuleRunnerService ruleRunnerService, - RuleRegistry ruleRegistry) + RuleRegistry ruleRegistry, + EventJsonSchemaGenerator eventJsonSchemaGenerator) : base(commandBus) { this.ruleEventsRepository = ruleEventsRepository; this.ruleQuery = ruleQuery; this.ruleRunnerService = ruleRunnerService; this.ruleRegistry = ruleRegistry; + this.eventJsonSchemaGenerator = eventJsonSchemaGenerator; } /// @@ -355,6 +359,47 @@ namespace Squidex.Areas.Api.Controllers.Rules return NoContent(); } + /// + /// Provide a list of all event types that are used in rules. + /// + /// + /// 200 => Rule events returned. + /// + [HttpGet] + [Route("rules/eventtypes")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [AllowAnonymous] + public IActionResult GetEventTypes() + { + var types = eventJsonSchemaGenerator.AllTypes; + + return Ok(types); + } + + /// + /// Provide the json schema for the event with the specified name. + /// + /// The name of the event. + /// + /// 200 => Rule event type found. + /// 404 => Rule event not found. + /// + [HttpGet] + [Route("rules/eventtypes/{name}")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [AllowAnonymous] + public IActionResult GetEventSchema(string name) + { + var schema = eventJsonSchemaGenerator.GetSchema(name); + + if (schema == null) + { + return NotFound(); + } + + return Content(schema.ToJson(), "application/json"); + } + private async Task InvokeCommandAsync(ICommand command) { var context = await CommandBus.PublishAsync(command); diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index 9b0571d05..ad88590b3 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -80,6 +80,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As().As(); + services.AddSingletonAs() + .AsSelf(); + services.AddSingletonAs() .As().AsSelf(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventJsonSchemaGeneratorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventJsonSchemaGeneratorTests.cs new file mode 100644 index 000000000..487998dba --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventJsonSchemaGeneratorTests.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using NJsonSchema.Generation; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.HandleRules +{ + public class EventJsonSchemaGeneratorTests + { + private readonly EventJsonSchemaGenerator sut; + + public EventJsonSchemaGeneratorTests() + { + var jsonSchemaGenerator = + new JsonSchemaGenerator( + new JsonSchemaGeneratorSettings()); + + sut = new EventJsonSchemaGenerator(jsonSchemaGenerator); + } + + public static IEnumerable AllTypes() + { + yield return nameof(EnrichedAssetEvent); + yield return nameof(EnrichedCommentEvent); + yield return nameof(EnrichedContentEvent); + yield return nameof(EnrichedManualEvent); + yield return nameof(EnrichedSchemaEvent); + yield return nameof(EnrichedUsageExceededEvent); + } + + public static IEnumerable AllTypesData() + { + return AllTypes().Select(x => new object[] { x }); + } + + [Fact] + public void Should_return_null_for_unknown_type_name() + { + var schema = sut.GetSchema("Unknown"); + + Assert.Null(schema); + } + + [Fact] + public void Should_provide_all_types() + { + var allTypes = sut.AllTypes; + + Assert.Equal(AllTypes().ToList(), allTypes); + } + + [Theory] + [MemberData(nameof(AllTypesData))] + public void Should_generate_json_schema_for_known_event(string typeName) + { + var schema = sut.GetSchema(typeName); + + Assert.NotNull(schema); + } + } +}