diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae4084e..28271b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ This release does not contain security updates. ### Added +- ⚡️ `OslcRequestParams` class for configuring request options (media types, headers, OSLC Core version). Parameters can be pre-set in the library, set in the `OslcClient` constructor, or overridden on a per-request basis. +- ⚡️ Graph accumulation mode in `OslcClient` via `EnableGraphAccumulation()` - useful for the initial discovery phase to accumulate all service provider information in a single graph. - `RootServicesHelper` was added to assist with processing OSLC Root Services documents. It can help with direct lookups (as long as your URI ends with `/rootservices` or `/rootservices.xml`), can look up a standard `/.well-known/oslc/rootservices.xml` location, or fall back to appending `/rootservices` for legacy systems. +- New overloads for `GetResourceAsync` and `CreateResourceAsync` accepting `OslcRequestParams` for per-request parameter customization. - ⚡️Samples for IBM Jazz ERM (aka Doors NG), ETM, and EWM were migrated to .NET 10 and tested against Jazz.net. You can run them yourself using `OSLC4Net_SDK\Examples\scripts\test-jazz_net.ps1`. @@ -25,10 +28,12 @@ This release does not contain security updates. - `OSLC4Net.Core` requires .NET 10 to be able to use the `[Experimental]` annotation. - `OSLC4Net.Client` requires .NET 10. +- `OslcClient` now has a `DefaultRequestParams` property for default request parameters configuration. - ❗️ `SignedByteNode` (which corresponds to `xsd:byte`) is now parsed as C# `sbyte` (signed byte) instead of `byte`. ### Deprecated +- 👉 `OslcRestClient` remains deprecated since 0.5.0. Use `OslcClient` instead. - Getters and setters for the RDF type (both `GetRdfTypes()` and `GetTypes()`) are deprecated in favor of the `.Types` property. diff --git a/OSLC4Net_SDK/OSLC4Net.Client/Oslc/OslcClient.cs b/OSLC4Net_SDK/OSLC4Net.Client/Oslc/OslcClient.cs index 8fe410db..6f5d1116 100644 --- a/OSLC4Net_SDK/OSLC4Net.Client/Oslc/OslcClient.cs +++ b/OSLC4Net_SDK/OSLC4Net.Client/Oslc/OslcClient.cs @@ -30,8 +30,16 @@ namespace OSLC4Net.Client.Oslc; /// -/// An OSLC Client. +/// An OSLC Client. This is the primary client for OSLC operations. /// +/// +/// OslcClient supports: +/// +/// Configurable request parameters via (can be set in constructor or per-request) +/// Access to both unmarshalled POCOs and raw response graphs via +/// Accumulating responses in a single graph for discovery phases via +/// +/// public class OslcClient : IDisposable { private readonly ILogger _logger; @@ -42,6 +50,17 @@ public class OslcClient : IDisposable protected readonly ISet _formatters; protected readonly HttpClient _client; + /// + /// Default request parameters used for all requests unless overridden. + /// + public OslcRequestParams DefaultRequestParams { get; } + + /// + /// When set, all response graphs will be merged into this graph. + /// Useful for the initial discovery phase to accumulate all service provider information. + /// + public Graph? AccumulatingGraph { get; private set; } + protected string AcceptHeader { get; } = "text/turtle;q=1.0, application/rdf+xml;q=0.9, application/n-triples;q=0.8, text/n3;q=0.7"; @@ -52,16 +71,28 @@ public OslcClient(ILogger logger) : this(false, logger) { } + /// + /// Initialize a new OslcClient with custom default request parameters. + /// + /// Logger instance. + /// Default request parameters for all requests. + public OslcClient(ILogger logger, OslcRequestParams defaultRequestParams) + : this(false, logger, defaultRequestParams) + { + } + /// /// Initialize a new OslcClient using an externally managed HttpClient (e.g. with resilience policies). /// /// Pre-configured HttpClient instance (lifetime managed by caller). /// Logger instance. - public OslcClient(HttpClient client, ILogger logger) + /// Optional default request parameters for all requests. + public OslcClient(HttpClient client, ILogger logger, OslcRequestParams? defaultRequestParams = null) { _logger = logger; _client = client; _formatters = new HashSet { new RdfXmlMediaTypeFormatter() }; + DefaultRequestParams = defaultRequestParams ?? OslcRequestParams.Default; } /// @@ -116,14 +147,23 @@ protected OslcClient(Func logger) : this(null, + private OslcClient(HttpClientHandler? customHandler, ILogger logger, + OslcRequestParams? defaultRequestParams = null) : this(null, customHandler, logger) { + DefaultRequestParams = defaultRequestParams ?? OslcRequestParams.Default; } private OslcClient(bool allowInvalidTlsCerts, ILogger logger) + : this(allowInvalidTlsCerts, logger, null) + { + } + + private OslcClient(bool allowInvalidTlsCerts, ILogger logger, + OslcRequestParams? defaultRequestParams) { _logger = logger; var handler = new HttpClientHandler @@ -146,13 +186,15 @@ private OslcClient(bool allowInvalidTlsCerts, ILogger logger) _formatters.Add(new RdfXmlMediaTypeFormatter()); _client = new HttpClient(handler); + DefaultRequestParams = defaultRequestParams ?? OslcRequestParams.Default; } public static OslcClient ForBasicAuth(string username, string password, ILogger logger, - HttpClientHandler handler = null) + HttpClientHandler? handler = null, + OslcRequestParams? defaultRequestParams = null) { - var oslcClient = new OslcClient(handler, logger); + var oslcClient = new OslcClient(handler, logger, defaultRequestParams); var client = oslcClient.GetHttpClient(); var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); client.DefaultRequestHeaders.Authorization = @@ -164,11 +206,11 @@ public static OslcClient ForBasicAuth(string username, string password, /// Create an OslcClient for Basic Auth using a pre-configured HttpClient (e.g. with resilience policies). /// public static OslcClient ForBasicAuth(HttpClient httpClient, string username, string password, - ILogger logger) + ILogger logger, OslcRequestParams? defaultRequestParams = null) { var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); - return new OslcClient(httpClient, logger); + return new OslcClient(httpClient, logger, defaultRequestParams); } /// @@ -180,10 +222,59 @@ public HttpClient GetHttpClient() return _client; } + /// + /// Enables graph accumulation mode. All response graphs will be merged into a single graph. + /// Useful for the initial discovery phase to accumulate all service provider information. + /// + /// The accumulating graph that will contain all merged response data. + public Graph EnableGraphAccumulation() + { + AccumulatingGraph ??= new Graph(); + return AccumulatingGraph; + } + + /// + /// Disables graph accumulation mode and optionally returns the accumulated graph. + /// + /// The accumulated graph, or null if accumulation was not enabled. + public Graph? DisableGraphAccumulation() + { + var result = AccumulatingGraph; + AccumulatingGraph = null; + return result; + } + + /// + /// Clears the accumulating graph without disabling accumulation mode. + /// + public void ClearAccumulatingGraph() + { + AccumulatingGraph?.Clear(); + } + public async Task> GetResourceAsync(string resourceUri, string? mediaType) where T : IExtendedResource, new() { - var httpResponseMessage = await GetResourceRawAsync(resourceUri, mediaType).ConfigureAwait(false); + return await GetResourceAsync(resourceUri, mediaType, null).ConfigureAwait(false); + } + + /// + /// Get an OSLC resource with request parameter overrides. + /// + /// The type of resource to retrieve. + /// The URI of the resource. + /// The media type to accept (deprecated, use requestParams instead). + /// Request parameters to override defaults. + /// An OslcResponse containing the resource(s) and graph. + public async Task> GetResourceAsync(string resourceUri, string? mediaType, + OslcRequestParams? requestParams) + where T : IExtendedResource, new() + { + var effectiveParams = DefaultRequestParams.Merge(requestParams); + var acceptHeader = mediaType ?? effectiveParams.AcceptHeader ?? AcceptHeader; + + var httpResponseMessage = await GetResourceRawAsync(resourceUri, acceptHeader, effectiveParams) + .ConfigureAwait(false); // REVISIT: according to the spec, non-success codes may also come with a RDF response - should, actually! (@berezovskyi 2024-10) // consider adding .ErrorResource to the OslcResponse if (httpResponseMessage.IsSuccessStatusCode && httpResponseMessage.Content is not null) @@ -205,6 +296,12 @@ public async Task> GetResourceAsync(string resourceUri, strin var graph = await httpResponseMessage.Content.ReadAsAsync(typeof(Graph), _formatters) .ConfigureAwait(false) as Graph; + // Merge into accumulating graph if enabled + if (AccumulatingGraph is not null && graph is not null) + { + AccumulatingGraph.Merge(graph); + } + return OslcResponse.WithSuccess(resources?.ToList(), graph, httpResponseMessage); } else @@ -238,12 +335,25 @@ public async Task> GetResourceAsync(string resourceUri, strin public Task> GetResourceAsync(string resourceUri) where T : IExtendedResource, new() { - return GetResourceAsync(resourceUri, null); + return GetResourceAsync(resourceUri, null, null); } public Task> GetResourceAsync(Uri typeURI) where T : IExtendedResource, new() { - return GetResourceAsync(typeURI.ToString(), null); + return GetResourceAsync(typeURI.ToString(), null, null); + } + + /// + /// Get an OSLC resource with request parameter overrides. + /// + /// The type of resource to retrieve. + /// The URI of the resource. + /// Request parameters to override defaults. + /// An OslcResponse containing the resource(s) and graph. + public Task> GetResourceAsync(Uri typeURI, OslcRequestParams? requestParams) + where T : IExtendedResource, new() + { + return GetResourceAsync(typeURI.ToString(), null, requestParams); } /// @@ -251,11 +361,37 @@ public async Task> GetResourceAsync(string resourceUri, strin /// public async Task GetResourceRawAsync(string url, string? mediaType = null) { + return await GetResourceRawAsync(url, mediaType, null).ConfigureAwait(false); + } + + /// + /// Consider using instead. + /// + /// The URL to fetch. + /// The Accept media type. + /// Optional request parameters to override defaults. + public async Task GetResourceRawAsync(string url, string? mediaType, + OslcRequestParams? requestParams) + { + var effectiveParams = DefaultRequestParams.Merge(requestParams); + var accept = mediaType ?? effectiveParams.AcceptHeader ?? AcceptHeader; + var oslcVersion = effectiveParams.OslcCoreVersion ?? OslcRequestParams.DefaultOslcCoreVersion; + _client.DefaultRequestHeaders.Accept.Clear(); - // TODO: use uniformly (@berezovskyi 2024-10) - _client.DefaultRequestHeaders.Accept.ParseAdd(mediaType ?? AcceptHeader); + _client.DefaultRequestHeaders.Accept.ParseAdd(accept); _client.DefaultRequestHeaders.Remove(OSLCConstants.OSLC_CORE_VERSION); - _client.DefaultRequestHeaders.Add(OSLCConstants.OSLC_CORE_VERSION, "2.0"); + _client.DefaultRequestHeaders.Add(OSLCConstants.OSLC_CORE_VERSION, oslcVersion); + + // Apply custom headers from request parameters + if (effectiveParams.CustomHeaders is not null) + { + foreach (var header in effectiveParams.CustomHeaders) + { + _client.DefaultRequestHeaders.Remove(header.Key); + _client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + HttpResponseMessage response; bool redirect; byte redirectCount = 0; @@ -396,7 +532,28 @@ public async Task DeleteResourceAsync(string url, Cancellat public async Task> CreateResourceAsync(string url, T artifact, string? mediaType = null) where T : IExtendedResource, new() { - var response = await CreateResourceRawAsync(url, artifact, mediaType).ConfigureAwait(false); + return await CreateResourceAsync(url, artifact, mediaType, null).ConfigureAwait(false); + } + + /// + /// Create an OSLC resource with request parameter overrides. + /// + /// The type of resource to create. + /// The creation factory URL. + /// The resource to create. + /// The Content-Type media type (deprecated, use requestParams instead). + /// Request parameters to override defaults. + /// An OslcResponse containing the created resource. + public async Task> CreateResourceAsync(string url, T artifact, string? mediaType, + OslcRequestParams? requestParams) + where T : IExtendedResource, new() + { + var effectiveParams = DefaultRequestParams.Merge(requestParams); + var contentType = mediaType ?? effectiveParams.ContentType ?? OSLCConstants.CT_RDF; + var acceptType = effectiveParams.AcceptHeader ?? AcceptHeader; + + var response = await CreateResourceRawAsync(url, artifact, contentType, acceptType, effectiveParams) + .ConfigureAwait(false); // a bit outside the spec, but these should be success statuses if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Created @@ -406,7 +563,7 @@ public async Task> CreateResourceAsync(string url, T artifact // we have two options: the Location header points to a newly created resource or the resource is returned directly // I think OSLC mandates Location, so let's start with that var createdUri = response.Headers.Location?.AbsoluteUri; - return await GetResourceAsync(createdUri, mediaType).ConfigureAwait(false); + return await GetResourceAsync(createdUri, acceptType, requestParams).ConfigureAwait(false); } else { @@ -444,14 +601,41 @@ public async Task CreateResourceRawAsync(Uri uri, IResource public async Task CreateResourceRawAsync(string url, IResource artifact, string mediaType, string acceptType) { + return await CreateResourceRawAsync(url, artifact, mediaType, acceptType, null).ConfigureAwait(false); + } + + /// + /// Create (POST) an artifact to a URL - usually an OSLC Creation Factory + /// + /// The creation factory URL. + /// The resource to create. + /// The Content-Type. + /// The Accept header. + /// Optional request parameters to apply custom headers. + /// The HTTP response. + public async Task CreateResourceRawAsync(string url, IResource artifact, string mediaType, + string acceptType, OslcRequestParams? requestParams) + { + var effectiveParams = DefaultRequestParams.Merge(requestParams); + var oslcVersion = effectiveParams.OslcCoreVersion ?? OslcRequestParams.DefaultOslcCoreVersion; + _client.DefaultRequestHeaders.Accept.Clear(); foreach (var acceptSingle in acceptType.Split(',')) { _client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(acceptSingle)); } - //_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(acceptType)); _client.DefaultRequestHeaders.Remove(OSLCConstants.OSLC_CORE_VERSION); - _client.DefaultRequestHeaders.Add(OSLCConstants.OSLC_CORE_VERSION, "2.0"); + _client.DefaultRequestHeaders.Add(OSLCConstants.OSLC_CORE_VERSION, oslcVersion); + + // Apply custom headers from request parameters + if (effectiveParams.CustomHeaders is not null) + { + foreach (var header in effectiveParams.CustomHeaders) + { + _client.DefaultRequestHeaders.Remove(header.Key); + _client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } var mediaTypeValue = new MediaTypeHeaderValue(mediaType); var formatter = diff --git a/OSLC4Net_SDK/OSLC4Net.Client/OslcRequestParams.cs b/OSLC4Net_SDK/OSLC4Net.Client/OslcRequestParams.cs new file mode 100644 index 00000000..e7ea0585 --- /dev/null +++ b/OSLC4Net_SDK/OSLC4Net.Client/OslcRequestParams.cs @@ -0,0 +1,205 @@ +/******************************************************************************* + * Copyright (c) 2025 Andrii Berezovskyi and OSLC4Net contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + *******************************************************************************/ + +using OSLC4Net.Client.Oslc; + +namespace OSLC4Net.Client; + +/// +/// Request parameters for OSLC client operations. These can be pre-set in the library, +/// configured in the OslcClient constructor, or overridden on a per-request basis. +/// +/// +/// This class allows users to customize: +/// +/// Accept header for content negotiation (which media types to request) +/// Content-Type header for POST/PUT operations +/// Custom headers for specific requests +/// OSLC Core version header +/// +/// +public sealed class OslcRequestParams +{ + /// + /// Default Accept header value for OSLC requests. + /// Prefers Turtle, then RDF/XML, then N-Triples, then N3. + /// + public const string DefaultAcceptHeader = + "text/turtle;q=1.0, application/rdf+xml;q=0.9, application/n-triples;q=0.8, text/n3;q=0.7"; + + /// + /// Default Content-Type for POST/PUT operations. + /// + public const string DefaultContentType = OSLCConstants.CT_RDF; + + /// + /// Default OSLC Core version. + /// + public const string DefaultOslcCoreVersion = "2.0"; + + /// + /// Accept header for content negotiation. + /// + public string? AcceptHeader { get; init; } + + /// + /// Content-Type header for POST/PUT operations. + /// + public string? ContentType { get; init; } + + /// + /// OSLC Core version header value. + /// + public string? OslcCoreVersion { get; init; } + + /// + /// Additional custom headers to include in requests. + /// + public IDictionary? CustomHeaders { get; init; } + + /// + /// Creates default request parameters. + /// + public static OslcRequestParams Default => new() + { + AcceptHeader = DefaultAcceptHeader, + ContentType = DefaultContentType, + OslcCoreVersion = DefaultOslcCoreVersion + }; + + /// + /// Creates request parameters for RDF/XML only requests. + /// Useful for root services discovery or legacy servers. + /// + public static OslcRequestParams RdfXmlOnly => new() + { + AcceptHeader = OSLCConstants.CT_RDF, + ContentType = OSLCConstants.CT_RDF, + OslcCoreVersion = DefaultOslcCoreVersion + }; + + /// + /// Creates request parameters for Turtle only requests. + /// + public static OslcRequestParams TurtleOnly => new() + { + AcceptHeader = "text/turtle", + ContentType = "text/turtle", + OslcCoreVersion = DefaultOslcCoreVersion + }; + + /// + /// Merges these parameters with overrides. Override values take precedence. + /// + /// Override parameters (can be null). + /// A new OslcRequestParams with merged values. + public OslcRequestParams Merge(OslcRequestParams? overrides) + { + if (overrides is null) + { + return this; + } + + var mergedHeaders = new Dictionary(); + + if (CustomHeaders is not null) + { + foreach (var kvp in CustomHeaders) + { + mergedHeaders[kvp.Key] = kvp.Value; + } + } + + if (overrides.CustomHeaders is not null) + { + foreach (var kvp in overrides.CustomHeaders) + { + mergedHeaders[kvp.Key] = kvp.Value; + } + } + + return new OslcRequestParams + { + AcceptHeader = overrides.AcceptHeader ?? AcceptHeader, + ContentType = overrides.ContentType ?? ContentType, + OslcCoreVersion = overrides.OslcCoreVersion ?? OslcCoreVersion, + CustomHeaders = mergedHeaders.Count > 0 ? mergedHeaders : null + }; + } + + /// + /// Creates a builder for fluent construction of request parameters. + /// + public static OslcRequestParamsBuilder Builder() => new(); +} + +/// +/// Builder for constructing OslcRequestParams in a fluent manner. +/// +public sealed class OslcRequestParamsBuilder +{ + private string? _acceptHeader; + private string? _contentType; + private string? _oslcCoreVersion; + private Dictionary? _customHeaders; + + /// + /// Sets the Accept header. + /// + public OslcRequestParamsBuilder WithAccept(string accept) + { + _acceptHeader = accept; + return this; + } + + /// + /// Sets the Content-Type header. + /// + public OslcRequestParamsBuilder WithContentType(string contentType) + { + _contentType = contentType; + return this; + } + + /// + /// Sets the OSLC Core version. + /// + public OslcRequestParamsBuilder WithOslcCoreVersion(string version) + { + _oslcCoreVersion = version; + return this; + } + + /// + /// Adds a custom header. + /// + public OslcRequestParamsBuilder WithHeader(string name, string value) + { + _customHeaders ??= []; + _customHeaders[name] = value; + return this; + } + + /// + /// Builds the OslcRequestParams. + /// + public OslcRequestParams Build() + { + return new OslcRequestParams + { + AcceptHeader = _acceptHeader, + ContentType = _contentType, + OslcCoreVersion = _oslcCoreVersion, + CustomHeaders = _customHeaders + }; + } +} diff --git a/OSLC4Net_SDK/Tests/OSLC4Net.Client.Tests/OslcClientTests.cs b/OSLC4Net_SDK/Tests/OSLC4Net.Client.Tests/OslcClientTests.cs new file mode 100644 index 00000000..01cd88c0 --- /dev/null +++ b/OSLC4Net_SDK/Tests/OSLC4Net.Client.Tests/OslcClientTests.cs @@ -0,0 +1,269 @@ +/******************************************************************************* + * Copyright (c) 2025 Andrii Berezovskyi and OSLC4Net contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + *******************************************************************************/ + +using System.Net; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OSLC4Net.Client.Oslc; +using TUnit.Core; + +namespace OSLC4Net.Client.Tests; + +public class OslcClientTests +{ + private IHost AppHost { get; } + private ILoggerFactory LoggerFactory { get; } + + public OslcClientTests() + { + AppHost = Host.CreateDefaultBuilder() + .ConfigureLogging(builder => + { + builder.AddConsole(); + }) + .Build(); + LoggerFactory = AppHost.Services.GetRequiredService(); + } + + [Test] + public async Task OslcClient_DefaultConstructor_HasDefaultRequestParams() + { + var client = new OslcClient(LoggerFactory.CreateLogger()); + + await Assert.That(client.DefaultRequestParams).IsNotNull(); + await Assert.That(client.DefaultRequestParams.AcceptHeader).IsEqualTo(OslcRequestParams.DefaultAcceptHeader); + await Assert.That(client.DefaultRequestParams.ContentType).IsEqualTo(OslcRequestParams.DefaultContentType); + + client.Dispose(); + } + + [Test] + public async Task OslcClient_WithCustomRequestParams_UsesProvidedParams() + { + var customParams = new OslcRequestParams + { + AcceptHeader = "custom/accept", + ContentType = "custom/content", + OslcCoreVersion = "3.0" + }; + + var client = new OslcClient(LoggerFactory.CreateLogger(), customParams); + + await Assert.That(client.DefaultRequestParams.AcceptHeader).IsEqualTo("custom/accept"); + await Assert.That(client.DefaultRequestParams.ContentType).IsEqualTo("custom/content"); + await Assert.That(client.DefaultRequestParams.OslcCoreVersion).IsEqualTo("3.0"); + + client.Dispose(); + } + + [Test] + public async Task OslcClient_ForBasicAuth_WithCustomRequestParams_UsesProvidedParams() + { + var customParams = new OslcRequestParams + { + AcceptHeader = "application/rdf+xml" + }; + + var client = OslcClient.ForBasicAuth( + "testuser", + "testpass", + LoggerFactory.CreateLogger(), + null, + customParams + ); + + await Assert.That(client.DefaultRequestParams.AcceptHeader).IsEqualTo("application/rdf+xml"); + + client.Dispose(); + } + + [Test] + public async Task EnableGraphAccumulation_ReturnsGraph() + { + var client = new OslcClient(LoggerFactory.CreateLogger()); + + var graph = client.EnableGraphAccumulation(); + + await Assert.That(graph).IsNotNull(); + await Assert.That(client.AccumulatingGraph).IsNotNull(); + await Assert.That(client.AccumulatingGraph).IsEqualTo(graph); + + client.Dispose(); + } + + [Test] + public async Task EnableGraphAccumulation_CalledTwice_ReturnsSameGraph() + { + var client = new OslcClient(LoggerFactory.CreateLogger()); + + var graph1 = client.EnableGraphAccumulation(); + var graph2 = client.EnableGraphAccumulation(); + + await Assert.That(graph1).IsEqualTo(graph2); + + client.Dispose(); + } + + [Test] + public async Task DisableGraphAccumulation_ReturnsAccumulatedGraph() + { + var client = new OslcClient(LoggerFactory.CreateLogger()); + + var enabledGraph = client.EnableGraphAccumulation(); + var disabledGraph = client.DisableGraphAccumulation(); + + await Assert.That(disabledGraph).IsEqualTo(enabledGraph); + await Assert.That(client.AccumulatingGraph).IsNull(); + + client.Dispose(); + } + + [Test] + public async Task DisableGraphAccumulation_WhenNotEnabled_ReturnsNull() + { + var client = new OslcClient(LoggerFactory.CreateLogger()); + + var graph = client.DisableGraphAccumulation(); + + await Assert.That(graph).IsNull(); + + client.Dispose(); + } + + [Test] + public async Task ClearAccumulatingGraph_ClearsGraphContent() + { + var client = new OslcClient(LoggerFactory.CreateLogger()); + + var graph = client.EnableGraphAccumulation(); + // Add a triple to the graph + var subj = graph.CreateUriNode(new Uri("http://example.org/subject")); + var pred = graph.CreateUriNode(new Uri("http://example.org/predicate")); + var obj = graph.CreateUriNode(new Uri("http://example.org/object")); + graph.Assert(new VDS.RDF.Triple(subj, pred, obj)); + + await Assert.That(graph.Triples.Count).IsGreaterThan(0); + + client.ClearAccumulatingGraph(); + + await Assert.That(graph.Triples.Count).IsEqualTo(0); + await Assert.That(client.AccumulatingGraph).IsNotNull(); // Still enabled + + client.Dispose(); + } + + [Test] + public async Task GetResourceRawAsync_AppliesCustomHeaders() + { + string? capturedAcceptHeader = null; + string? capturedOslcVersionHeader = null; + string? capturedCustomHeader = null; + + var handler = new FakeHttpMessageHandler((req, ct) => + { + // Capture headers from the request + if (req.Headers.TryGetValues("Accept", out var acceptValues)) + { + capturedAcceptHeader = string.Join(", ", acceptValues); + } + if (req.Headers.TryGetValues("OSLC-Core-Version", out var oslcValues)) + { + capturedOslcVersionHeader = string.Join(", ", oslcValues); + } + if (req.Headers.TryGetValues("X-Custom-Header", out var customValues)) + { + capturedCustomHeader = string.Join(", ", customValues); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("", + Encoding.UTF8, "application/rdf+xml"), + RequestMessage = req + }); + }); + + var httpClient = new HttpClient(handler); + var customParams = new OslcRequestParams + { + AcceptHeader = "application/rdf+xml", + OslcCoreVersion = "2.0", + CustomHeaders = new Dictionary + { + { "X-Custom-Header", "CustomValue" } + } + }; + + var client = new OslcClient(httpClient, LoggerFactory.CreateLogger(), customParams); + + await client.GetResourceRawAsync("http://example.com/resource"); + + await Assert.That(capturedAcceptHeader).Contains("application/rdf+xml"); + await Assert.That(capturedOslcVersionHeader).IsEqualTo("2.0"); + await Assert.That(capturedCustomHeader).IsEqualTo("CustomValue"); + } + + [Test] + public async Task GetResourceRawAsync_WithPerRequestOverride_UsesOverride() + { + string? capturedAcceptHeader = null; + + var handler = new FakeHttpMessageHandler((req, ct) => + { + if (req.Headers.TryGetValues("Accept", out var acceptValues)) + { + capturedAcceptHeader = string.Join(", ", acceptValues); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("", + Encoding.UTF8, "application/rdf+xml"), + RequestMessage = req + }); + }); + + var httpClient = new HttpClient(handler); + var defaultParams = new OslcRequestParams + { + AcceptHeader = "application/rdf+xml" + }; + + var client = new OslcClient(httpClient, LoggerFactory.CreateLogger(), defaultParams); + + var overrideParams = new OslcRequestParams + { + AcceptHeader = "text/turtle" + }; + + await client.GetResourceRawAsync("http://example.com/resource", null, overrideParams); + + await Assert.That(capturedAcceptHeader).Contains("text/turtle"); + } + + [Test] + public async Task HttpClient_Constructor_AcceptsPreConfiguredClient() + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("X-Pre-Configured", "true"); + + // When using an externally-provided HttpClient, the OslcClient does NOT own it, + // so we must ensure the HttpClient is not disposed when the OslcClient is disposed. + // In this case, we manage the HttpClient with using and don't dispose the OslcClient. + var client = new OslcClient(httpClient, LoggerFactory.CreateLogger()); + + await Assert.That(client.GetHttpClient()).IsEqualTo(httpClient); + await Assert.That(client.GetHttpClient().DefaultRequestHeaders.Contains("X-Pre-Configured")).IsTrue(); + } +} diff --git a/OSLC4Net_SDK/Tests/OSLC4Net.Client.Tests/OslcRequestParamsTests.cs b/OSLC4Net_SDK/Tests/OSLC4Net.Client.Tests/OslcRequestParamsTests.cs new file mode 100644 index 00000000..dbab5163 --- /dev/null +++ b/OSLC4Net_SDK/Tests/OSLC4Net.Client.Tests/OslcRequestParamsTests.cs @@ -0,0 +1,154 @@ +/******************************************************************************* + * Copyright (c) 2025 Andrii Berezovskyi and OSLC4Net contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Eclipse Distribution License is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + *******************************************************************************/ + +using TUnit.Core; + +namespace OSLC4Net.Client.Tests; + +public class OslcRequestParamsTests +{ + [Test] + public async Task Default_HasExpectedValues() + { + var defaultParams = OslcRequestParams.Default; + + await Assert.That(defaultParams.AcceptHeader).IsEqualTo(OslcRequestParams.DefaultAcceptHeader); + await Assert.That(defaultParams.ContentType).IsEqualTo(OslcRequestParams.DefaultContentType); + await Assert.That(defaultParams.OslcCoreVersion).IsEqualTo("2.0"); + await Assert.That(defaultParams.CustomHeaders).IsNull(); + } + + [Test] + public async Task RdfXmlOnly_HasRdfXmlValues() + { + var rdfXmlParams = OslcRequestParams.RdfXmlOnly; + + await Assert.That(rdfXmlParams.AcceptHeader).IsEqualTo("application/rdf+xml"); + await Assert.That(rdfXmlParams.ContentType).IsEqualTo("application/rdf+xml"); + await Assert.That(rdfXmlParams.OslcCoreVersion).IsEqualTo("2.0"); + } + + [Test] + public async Task TurtleOnly_HasTurtleValues() + { + var turtleParams = OslcRequestParams.TurtleOnly; + + await Assert.That(turtleParams.AcceptHeader).IsEqualTo("text/turtle"); + await Assert.That(turtleParams.ContentType).IsEqualTo("text/turtle"); + await Assert.That(turtleParams.OslcCoreVersion).IsEqualTo("2.0"); + } + + [Test] + public async Task Merge_WithNullOverride_ReturnsSelf() + { + var original = OslcRequestParams.Default; + + var result = original.Merge(null); + + await Assert.That(result).IsEqualTo(original); + } + + [Test] + public async Task Merge_WithOverride_UsesOverrideValues() + { + var original = OslcRequestParams.Default; + var overrides = new OslcRequestParams + { + AcceptHeader = "custom/accept", + ContentType = null // Should keep original + }; + + var result = original.Merge(overrides); + + await Assert.That(result.AcceptHeader).IsEqualTo("custom/accept"); + await Assert.That(result.ContentType).IsEqualTo(original.ContentType); + await Assert.That(result.OslcCoreVersion).IsEqualTo(original.OslcCoreVersion); + } + + [Test] + public async Task Merge_CombinesCustomHeaders() + { + var original = new OslcRequestParams + { + AcceptHeader = "original/accept", + CustomHeaders = new Dictionary + { + { "Header1", "Value1" }, + { "Header2", "Value2" } + } + }; + var overrides = new OslcRequestParams + { + CustomHeaders = new Dictionary + { + { "Header2", "OverriddenValue2" }, + { "Header3", "Value3" } + } + }; + + var result = original.Merge(overrides); + + await Assert.That(result.CustomHeaders).IsNotNull(); + await Assert.That(result.CustomHeaders!.Count).IsEqualTo(3); + await Assert.That(result.CustomHeaders["Header1"]).IsEqualTo("Value1"); + await Assert.That(result.CustomHeaders["Header2"]).IsEqualTo("OverriddenValue2"); + await Assert.That(result.CustomHeaders["Header3"]).IsEqualTo("Value3"); + } + + [Test] + public async Task Builder_CreatesParamsCorrectly() + { + var params1 = OslcRequestParams.Builder() + .WithAccept("custom/accept") + .WithContentType("custom/content") + .WithOslcCoreVersion("3.0") + .WithHeader("X-Custom", "CustomValue") + .Build(); + + await Assert.That(params1.AcceptHeader).IsEqualTo("custom/accept"); + await Assert.That(params1.ContentType).IsEqualTo("custom/content"); + await Assert.That(params1.OslcCoreVersion).IsEqualTo("3.0"); + await Assert.That(params1.CustomHeaders).IsNotNull(); + await Assert.That(params1.CustomHeaders!["X-Custom"]).IsEqualTo("CustomValue"); + } + + [Test] + public async Task Builder_WithMultipleHeaders_AddsAll() + { + var params1 = OslcRequestParams.Builder() + .WithHeader("Header1", "Value1") + .WithHeader("Header2", "Value2") + .Build(); + + await Assert.That(params1.CustomHeaders).IsNotNull(); + await Assert.That(params1.CustomHeaders!.Count).IsEqualTo(2); + await Assert.That(params1.CustomHeaders["Header1"]).IsEqualTo("Value1"); + await Assert.That(params1.CustomHeaders["Header2"]).IsEqualTo("Value2"); + } + + [Test] + public async Task DefaultContentType_IsRdfXml() + { + await Assert.That(OslcRequestParams.DefaultContentType).IsEqualTo("application/rdf+xml"); + } + + [Test] + public async Task DefaultAcceptHeader_IncludesMultipleFormats() + { + var acceptHeader = OslcRequestParams.DefaultAcceptHeader; + + await Assert.That(acceptHeader).Contains("text/turtle"); + await Assert.That(acceptHeader).Contains("application/rdf+xml"); + await Assert.That(acceptHeader).Contains("application/n-triples"); + await Assert.That(acceptHeader).Contains("text/n3"); + } +}