Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,23 @@ 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`.


### Changed

- `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.

Expand Down
219 changes: 202 additions & 17 deletions OSLC4Net_SDK/OSLC4Net.Client/Oslc/OslcClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@
namespace OSLC4Net.Client.Oslc;

/// <summary>
/// An OSLC Client.
/// An OSLC Client. This is the primary client for OSLC operations.
/// </summary>
/// <remarks>
/// OslcClient supports:
/// <list type="bullet">
/// <item>Configurable request parameters via <see cref="OslcRequestParams"/> (can be set in constructor or per-request)</item>
/// <item>Access to both unmarshalled POCOs and raw response graphs via <see cref="OslcResponse{T}"/></item>
/// <item>Accumulating responses in a single graph for discovery phases via <see cref="AccumulatingGraph"/></item>
/// </list>
/// </remarks>
public class OslcClient : IDisposable
{
private readonly ILogger<OslcClient> _logger;
Expand All @@ -42,6 +50,17 @@ public class OslcClient : IDisposable
protected readonly ISet<MediaTypeFormatter> _formatters;
protected readonly HttpClient _client;

/// <summary>
/// Default request parameters used for all requests unless overridden.
/// </summary>
public OslcRequestParams DefaultRequestParams { get; }

/// <summary>
/// When set, all response graphs will be merged into this graph.
/// Useful for the initial discovery phase to accumulate all service provider information.
/// </summary>
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";

Expand All @@ -52,16 +71,28 @@ public OslcClient(ILogger<OslcClient> logger) : this(false, logger)
{
}

/// <summary>
/// Initialize a new OslcClient with custom default request parameters.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="defaultRequestParams">Default request parameters for all requests.</param>
public OslcClient(ILogger<OslcClient> logger, OslcRequestParams defaultRequestParams)
: this(false, logger, defaultRequestParams)
{
}

/// <summary>
/// Initialize a new OslcClient using an externally managed HttpClient (e.g. with resilience policies).
/// </summary>
/// <param name="client">Pre-configured HttpClient instance (lifetime managed by caller).</param>
/// <param name="logger">Logger instance.</param>
public OslcClient(HttpClient client, ILogger<OslcClient> logger)
/// <param name="defaultRequestParams">Optional default request parameters for all requests.</param>
public OslcClient(HttpClient client, ILogger<OslcClient> logger, OslcRequestParams? defaultRequestParams = null)
{
_logger = logger;
_client = client;
_formatters = new HashSet<MediaTypeFormatter> { new RdfXmlMediaTypeFormatter() };
DefaultRequestParams = defaultRequestParams ?? OslcRequestParams.Default;
}

/// <summary>
Expand Down Expand Up @@ -116,14 +147,23 @@ protected OslcClient(Func<HttpRequestMessage, X509Certificate2, X509Chain,
}

_client = HttpClientFactory.Create(handler);
DefaultRequestParams = OslcRequestParams.Default;
}

private OslcClient(HttpClientHandler customHandler, ILogger<OslcClient> logger) : this(null,
private OslcClient(HttpClientHandler? customHandler, ILogger<OslcClient> logger,
OslcRequestParams? defaultRequestParams = null) : this(null,
customHandler, logger)
{
DefaultRequestParams = defaultRequestParams ?? OslcRequestParams.Default;
}

private OslcClient(bool allowInvalidTlsCerts, ILogger<OslcClient> logger)
: this(allowInvalidTlsCerts, logger, null)
{
}

private OslcClient(bool allowInvalidTlsCerts, ILogger<OslcClient> logger,
OslcRequestParams? defaultRequestParams)
{
_logger = logger;
var handler = new HttpClientHandler
Expand All @@ -144,13 +184,16 @@ private OslcClient(bool allowInvalidTlsCerts, ILogger<OslcClient> logger)
_formatters.Add(new RdfXmlMediaTypeFormatter());

_client = new HttpClient(handler);
DefaultRequestParams = defaultRequestParams ?? OslcRequestParams.Default;
}


public static OslcClient ForBasicAuth(string username, string password,
ILogger<OslcClient> 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 =
Expand All @@ -162,11 +205,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).
/// </summary>
public static OslcClient ForBasicAuth(HttpClient httpClient, string username, string password,
ILogger<OslcClient> logger)
ILogger<OslcClient> 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);
}

/// <summary>
Expand All @@ -178,11 +221,60 @@ public HttpClient GetHttpClient()
return _client;
}

/// <summary>
/// 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.
/// </summary>
/// <returns>The accumulating graph that will contain all merged response data.</returns>
public Graph EnableGraphAccumulation()
{
AccumulatingGraph ??= new Graph();
return AccumulatingGraph;
}

/// <summary>
/// Disables graph accumulation mode and optionally returns the accumulated graph.
/// </summary>
/// <returns>The accumulated graph, or null if accumulation was not enabled.</returns>
public Graph? DisableGraphAccumulation()
{
var result = AccumulatingGraph;
AccumulatingGraph = null;
return result;
}

/// <summary>
/// Clears the accumulating graph without disabling accumulation mode.
/// </summary>
public void ClearAccumulatingGraph()
{
AccumulatingGraph?.Clear();
}


public async Task<OslcResponse<T>> GetResourceAsync<T>(string resourceUri, string? mediaType)
where T : IExtendedResource, new()
{
var httpResponseMessage = await GetResourceRawAsync(resourceUri, mediaType).ConfigureAwait(false);
return await GetResourceAsync<T>(resourceUri, mediaType, null).ConfigureAwait(false);
}

/// <summary>
/// Get an OSLC resource with request parameter overrides.
/// </summary>
/// <typeparam name="T">The type of resource to retrieve.</typeparam>
/// <param name="resourceUri">The URI of the resource.</param>
/// <param name="mediaType">The media type to accept (deprecated, use requestParams instead).</param>
/// <param name="requestParams">Request parameters to override defaults.</param>
/// <returns>An OslcResponse containing the resource(s) and graph.</returns>
public async Task<OslcResponse<T>> GetResourceAsync<T>(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)
Expand All @@ -204,6 +296,12 @@ public async Task<OslcResponse<T>> GetResourceAsync<T>(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<T>.WithSuccess(resources?.ToList(), graph, httpResponseMessage);
}
else
Expand Down Expand Up @@ -237,25 +335,64 @@ public async Task<OslcResponse<T>> GetResourceAsync<T>(string resourceUri, strin

public Task<OslcResponse<T>> GetResourceAsync<T>(string resourceUri) where T : IExtendedResource, new()
{
return GetResourceAsync<T>(resourceUri, null);
return GetResourceAsync<T>(resourceUri, null, null);
}


public Task<OslcResponse<T>> GetResourceAsync<T>(Uri typeURI) where T : IExtendedResource, new()
{
return GetResourceAsync<T>(typeURI.ToString(), null);
return GetResourceAsync<T>(typeURI.ToString(), null, null);
}

/// <summary>
/// Get an OSLC resource with request parameter overrides.
/// </summary>
/// <typeparam name="T">The type of resource to retrieve.</typeparam>
/// <param name="typeURI">The URI of the resource.</param>
/// <param name="requestParams">Request parameters to override defaults.</param>
/// <returns>An OslcResponse containing the resource(s) and graph.</returns>
public Task<OslcResponse<T>> GetResourceAsync<T>(Uri typeURI, OslcRequestParams? requestParams)
where T : IExtendedResource, new()
{
return GetResourceAsync<T>(typeURI.ToString(), null, requestParams);
}

/// <summary>
/// Consider using <see cref="GetResourceAsync{T}"/> instead.
/// </summary>
public async Task<HttpResponseMessage> GetResourceRawAsync(string url, string? mediaType = null)
{
return await GetResourceRawAsync(url, mediaType, null).ConfigureAwait(false);
}

/// <summary>
/// Consider using <see cref="GetResourceAsync{T}"/> instead.
/// </summary>
/// <param name="url">The URL to fetch.</param>
/// <param name="mediaType">The Accept media type.</param>
/// <param name="requestParams">Optional request parameters to override defaults.</param>
public async Task<HttpResponseMessage> 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;
Expand Down Expand Up @@ -396,7 +533,28 @@ public async Task<HttpResponseMessage> DeleteResourceAsync(string url, Cancellat
public async Task<OslcResponse<T>> CreateResourceAsync<T>(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);
}

/// <summary>
/// Create an OSLC resource with request parameter overrides.
/// </summary>
/// <typeparam name="T">The type of resource to create.</typeparam>
/// <param name="url">The creation factory URL.</param>
/// <param name="artifact">The resource to create.</param>
/// <param name="mediaType">The Content-Type media type (deprecated, use requestParams instead).</param>
/// <param name="requestParams">Request parameters to override defaults.</param>
/// <returns>An OslcResponse containing the created resource.</returns>
public async Task<OslcResponse<T>> CreateResourceAsync<T>(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
Expand All @@ -406,7 +564,7 @@ public async Task<OslcResponse<T>> CreateResourceAsync<T>(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<T>(createdUri, mediaType).ConfigureAwait(false);
return await GetResourceAsync<T>(createdUri, acceptType, requestParams).ConfigureAwait(false);
}
else
{
Expand Down Expand Up @@ -444,14 +602,41 @@ public async Task<HttpResponseMessage> CreateResourceRawAsync(Uri uri, IResource
public async Task<HttpResponseMessage> CreateResourceRawAsync(string url, IResource artifact, string mediaType,
string acceptType)
{
return await CreateResourceRawAsync(url, artifact, mediaType, acceptType, null).ConfigureAwait(false);
}

/// <summary>
/// Create (POST) an artifact to a URL - usually an OSLC Creation Factory
/// </summary>
/// <param name="url">The creation factory URL.</param>
/// <param name="artifact">The resource to create.</param>
/// <param name="mediaType">The Content-Type.</param>
/// <param name="acceptType">The Accept header.</param>
/// <param name="requestParams">Optional request parameters to apply custom headers.</param>
/// <returns>The HTTP response.</returns>
public async Task<HttpResponseMessage> 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 =
Expand Down
Loading
Loading