diff --git a/ArchiSteamFarm/ArchiSteamFarm.csproj b/ArchiSteamFarm/ArchiSteamFarm.csproj index 95541f9b9c55b..8280155183810 100644 --- a/ArchiSteamFarm/ArchiSteamFarm.csproj +++ b/ArchiSteamFarm/ArchiSteamFarm.csproj @@ -16,7 +16,8 @@ - + + diff --git a/ArchiSteamFarm/IPC/ArchiKestrel.cs b/ArchiSteamFarm/IPC/ArchiKestrel.cs index e7aac2ce52f85..9303ec9f9282b 100644 --- a/ArchiSteamFarm/IPC/ArchiKestrel.cs +++ b/ArchiSteamFarm/IPC/ArchiKestrel.cs @@ -36,6 +36,7 @@ using ArchiSteamFarm.IPC.Controllers.Api; using ArchiSteamFarm.IPC.Integration; using ArchiSteamFarm.IPC.OpenApi; +using ArchiSteamFarm.IPC.Swagger; using ArchiSteamFarm.Localization; using ArchiSteamFarm.NLog; using ArchiSteamFarm.NLog.Targets; @@ -53,6 +54,7 @@ using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi.Models; using NLog.Web; using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; @@ -255,7 +257,11 @@ private static void ConfigureApp([SuppressMessage("ReSharper", "SuggestBaseTypeF app.MapControllers(); // Add support for OpenAPI, responsible for automatic API documentation generation, this should be on the end, once we're done with API - app.MapOpenApi("/swagger/{documentName}/swagger.json"); + if (Program.UseOpenApi) { + app.MapOpenApi("/swagger/{documentName}/swagger.json"); + } else { + app.UseSwagger(); + } // Add support for swagger UI, this should be after swagger, obviously app.UseSwaggerUI( @@ -332,13 +338,71 @@ private static void ConfigureServices([SuppressMessage("ReSharper", "SuggestBase } // Add support for OpenAPI, responsible for automatic API documentation generation - services.AddOpenApi( - SharedInfo.ASF, static options => { - options.AddDocumentTransformer(); - options.AddOperationTransformer(); - options.AddSchemaTransformer(); - } - ); + if (Program.UseOpenApi) { + services.AddOpenApi( + SharedInfo.ASF, static options => { + options.AddDocumentTransformer(); + options.AddOperationTransformer(); + options.AddSchemaTransformer(); + } + ); + } else { + services.AddSwaggerGen( + static options => { + options.AddSecurityDefinition( + nameof(GlobalConfig.IPCPassword), new OpenApiSecurityScheme { + Description = $"{nameof(GlobalConfig.IPCPassword)} authentication using request headers. Check {SharedInfo.ProjectURL}/wiki/IPC#authentication for more info.", + In = ParameterLocation.Header, + Name = ApiAuthenticationMiddleware.HeadersField, + Type = SecuritySchemeType.ApiKey + } + ); + + options.AddSecurityRequirement( + new OpenApiSecurityRequirement { + { + new OpenApiSecurityScheme { + Reference = new OpenApiReference { + Id = nameof(GlobalConfig.IPCPassword), + Type = ReferenceType.SecurityScheme + } + }, + + [] + } + } + ); + + // We require custom schema IDs due to conflicting type names, choosing the proper one is tricky as there is no good answer and any kind of convention has a potential to create conflict + // FullName and Name both do, ToString() for unknown to me reason doesn't, and I don't have courage to call our WebUtilities.GetUnifiedName() better than what .NET ships with (because it isn't) + // Let's use ToString() until we find a good enough reason to change it, also, the name must pass ^[a-zA-Z0-9.-_]+$ regex + options.CustomSchemaIds(static type => type.ToString().Replace('+', '-')); + + options.EnableAnnotations(true, true); + + options.SchemaFilter(); + options.SchemaFilter(); + options.SchemaFilter(); + + options.SwaggerDoc( + SharedInfo.ASF, new OpenApiInfo { + Contact = new OpenApiContact { + Name = SharedInfo.GithubRepo, + Url = new Uri(SharedInfo.ProjectURL) + }, + + License = new OpenApiLicense { + Name = SharedInfo.LicenseName, + Url = new Uri(SharedInfo.LicenseURL) + }, + + Title = $"{SharedInfo.AssemblyName} API", + Version = SharedInfo.Version.ToString() + } + ); + } + ); + } // Add support for optional healtchecks services.AddHealthChecks(); diff --git a/ArchiSteamFarm/IPC/Swagger/CustomAttributesSchemaFilter.cs b/ArchiSteamFarm/IPC/Swagger/CustomAttributesSchemaFilter.cs new file mode 100644 index 0000000000000..d99c18dcdbd1a --- /dev/null +++ b/ArchiSteamFarm/IPC/Swagger/CustomAttributesSchemaFilter.cs @@ -0,0 +1,53 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// | +// Copyright 2015-2025 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Reflection; +using ArchiSteamFarm.IPC.Integration; +using JetBrains.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ArchiSteamFarm.IPC.Swagger; + +[UsedImplicitly] +internal sealed class CustomAttributesSchemaFilter : ISchemaFilter { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(context); + + ICustomAttributeProvider attributesProvider; + + if (context.MemberInfo != null) { + attributesProvider = context.MemberInfo; + } else if (context.ParameterInfo != null) { + attributesProvider = context.ParameterInfo; + } else { + return; + } + + foreach (CustomSwaggerAttribute customSwaggerAttribute in attributesProvider.GetCustomAttributes(typeof(CustomSwaggerAttribute), true)) { + customSwaggerAttribute.Apply(schema); + } + } +} diff --git a/ArchiSteamFarm/IPC/Swagger/EnumSchemaFilter.cs b/ArchiSteamFarm/IPC/Swagger/EnumSchemaFilter.cs new file mode 100644 index 0000000000000..4eb9c87b732bb --- /dev/null +++ b/ArchiSteamFarm/IPC/Swagger/EnumSchemaFilter.cs @@ -0,0 +1,107 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// | +// Copyright 2015-2025 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; +using JetBrains.Annotations; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ArchiSteamFarm.IPC.Swagger; + +[UsedImplicitly] +internal sealed class EnumSchemaFilter : ISchemaFilter { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(context); + + if (context.Type is not { IsEnum: true }) { + return; + } + + if (context.Type.IsDefined(typeof(FlagsAttribute), false)) { + schema.Format = "flags"; + } + + OpenApiObject definition = new(); + + foreach (object? enumValue in context.Type.GetEnumValues()) { + if (enumValue == null) { + throw new InvalidOperationException(nameof(enumValue)); + } + + string? enumName = Enum.GetName(context.Type, enumValue); + + if (string.IsNullOrEmpty(enumName)) { + // Fallback + enumName = enumValue.ToString(); + + if (string.IsNullOrEmpty(enumName)) { + throw new InvalidOperationException(nameof(enumName)); + } + } + + if (definition.ContainsKey(enumName)) { + // This is possible if we have multiple names for the same enum value, we'll ignore additional ones + continue; + } + + IOpenApiPrimitive enumObject; + + if (TryCast(enumValue, out int intValue)) { + enumObject = new OpenApiInteger(intValue); + } else if (TryCast(enumValue, out long longValue)) { + enumObject = new OpenApiLong(longValue); + } else if (TryCast(enumValue, out ulong ulongValue)) { + // OpenApi spec doesn't support ulongs as of now + enumObject = new OpenApiString(ulongValue.ToString(CultureInfo.InvariantCulture)); + } else { + throw new InvalidOperationException(nameof(enumValue)); + } + + definition.Add(enumName, enumObject); + } + + schema.AddExtension("x-definition", definition); + } + + private static bool TryCast(object value, out T typedValue) where T : struct { + ArgumentNullException.ThrowIfNull(value); + + try { + typedValue = (T) Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); + + return true; + } catch (InvalidCastException) { + typedValue = default(T); + + return false; + } catch (OverflowException) { + typedValue = default(T); + + return false; + } + } +} diff --git a/ArchiSteamFarm/IPC/Swagger/ReadOnlyFixesSchemaFilter.cs b/ArchiSteamFarm/IPC/Swagger/ReadOnlyFixesSchemaFilter.cs new file mode 100644 index 0000000000000..d798fc1e23e33 --- /dev/null +++ b/ArchiSteamFarm/IPC/Swagger/ReadOnlyFixesSchemaFilter.cs @@ -0,0 +1,42 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// | +// Copyright 2015-2025 Łukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Reflection; +using JetBrains.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ArchiSteamFarm.IPC.Swagger; + +[UsedImplicitly] +internal sealed class ReadOnlyFixesSchemaFilter : ISchemaFilter { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(context); + + if (schema.ReadOnly && context.MemberInfo is PropertyInfo { CanWrite: true }) { + schema.ReadOnly = false; + } + } +} diff --git a/ArchiSteamFarm/Program.cs b/ArchiSteamFarm/Program.cs index ddc247b5d333c..4124058182889 100644 --- a/ArchiSteamFarm/Program.cs +++ b/ArchiSteamFarm/Program.cs @@ -59,6 +59,7 @@ internal static class Program { internal static bool Service { get; private set; } internal static bool ShutdownSequenceInitialized { get; private set; } internal static bool SteamParentalGeneration { get; private set; } = true; + internal static bool UseOpenApi { get; private set; } private static readonly Dictionary RegisteredPosixSignals = new(); private static readonly TaskCompletionSource ShutdownResetEvent = new(); @@ -610,6 +611,10 @@ private static async Task ParseArgs(IReadOnlyCollection args) { case "--SYSTEM-REQUIRED" when noArgumentValueNext(): SystemRequired = true; + break; + case "--USE-OPENAPI" when noArgumentValueNext(): + UseOpenApi = true; + break; default: if (cryptKeyNext) { diff --git a/Directory.Packages.props b/Directory.Packages.props index 5686d11752d0b..e07c2dbb9615f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,8 @@ - + +