Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,6 @@ healthchecksdb

# ASP.NET Core
appsettings.Production.json

# LibMan local dependencies
src/StrictDocOslcRmServer/StrictDocOslcRm/wwwroot/lib/
5 changes: 4 additions & 1 deletion src/StrictDocOslcRmServer/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
<PackageVersion Include="OSLC4Net.ChangeManagementCommon" Version="$(Oslc4NetVersion)" />
<PackageVersion Include="OSLC4Net.Domains.RequirementsManagement" Version="$(Oslc4NetVersion)" />

<!-- Build/misc -->
<PackageVersion Include="Microsoft.Web.LibraryManager.Build" Version="2.1.175" />

<!-- Test -->
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="$(DotnetRuntimeVersion)" />
<PackageVersion Include="TUnit" Version="1.59.0" />
<PackageVersion Include="Verify.TUnit" Version="31.13.5" />
<PackageVersion Include="Microsoft.Testing.Extensions.TrxReport" Version="2.3.1" />
<PackageVersion Include="Microsoft.Testing.Extensions.CrashDump" Version="2.2.1" />
<PackageVersion Include="Microsoft.Testing.Extensions.HangDump" Version="2.3.1" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />

</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Net;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NSubstitute;
using OSLC4Net.Domains.RequirementsManagement;
using OSLC4Net.Server.Providers;
using StrictDocOslcRm.Services;

namespace StrictDocOslcRm.Tests;

public class RequirementControllerIntegrationTests
{
private static IHost CreateHost(
IStrictDocService strictDocService,
IBaseUrlService baseUrlService,
ILinkSidecarService linkSidecarService,

Check failure on line 20 in src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/RequirementControllerIntegrationTests.cs

View workflow job for this annotation

GitHub Actions / test (ubuntu-24.04, 10.x, net10.0)

The type or namespace name 'ILinkSidecarService' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 20 in src/StrictDocOslcRmServer/StrictDocOslcRm.Tests/RequirementControllerIntegrationTests.cs

View workflow job for this annotation

GitHub Actions / test (ubuntu-24.04, 10.x, net10.0)

The type or namespace name 'ILinkSidecarService' could not be found (are you missing a using directive or an assembly reference?)
IOslcQueryService oslcQueryService)
{
var host = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder.UseTestServer();
webBuilder.ConfigureServices(services =>
{
services.AddControllers(options =>
{
options.OutputFormatters.Insert(0, new OslcRdfOutputFormatter());
options.InputFormatters.Insert(0, new OslcRdfInputFormatter());
})
.AddApplicationPart(typeof(StrictDocOslcRm.Controllers.RequirementController).Assembly);

services.AddSingleton(strictDocService);
services.AddSingleton(baseUrlService);
services.AddSingleton(linkSidecarService);
services.AddSingleton(oslcQueryService);
});
webBuilder.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());
});
})
.Start();

return host;
}

[Test]
public async Task GetRequirement_ReturnsTurtle()
{
// Arrange
var strictDocService = Substitute.For<IStrictDocService>();
var baseUrlService = Substitute.For<IBaseUrlService>();
var linkSidecarService = Substitute.For<ILinkSidecarService>();
var oslcQueryService = Substitute.For<IOslcQueryService>();

var uid = "REQ-001";
var baseUrl = "http://localhost";
baseUrlService.GetBaseUrl().Returns(baseUrl);

var requirement = new Requirement
{
Identifier = uid,
Title = "Test Integration Requirement",
Description = "This is a test integration requirement"
};
strictDocService.GetAllRequirementsAsync(baseUrl).Returns(new List<Requirement> { requirement });

using var host = CreateHost(strictDocService, baseUrlService, linkSidecarService, oslcQueryService);
using var client = host.GetTestClient();

// Act
using var request = new HttpRequestMessage(HttpMethod.Get, $"/?a={uid}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/turtle"));
using var response = await client.SendAsync(request);

// Assert
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();

await Assert.That(content).Contains("Test Integration Requirement");
await Assert.That(content).Contains("This is a test integration requirement");
await Assert.That(content).Contains("http://open-services.net/ns/rm#Requirement");
}

[Test]
public async Task GetRequirement_ReturnsRdfXml()
{
// Arrange
var strictDocService = Substitute.For<IStrictDocService>();
var baseUrlService = Substitute.For<IBaseUrlService>();
var linkSidecarService = Substitute.For<ILinkSidecarService>();
var oslcQueryService = Substitute.For<IOslcQueryService>();

var uid = "REQ-002";
var baseUrl = "http://localhost";
baseUrlService.GetBaseUrl().Returns(baseUrl);

var requirement = new Requirement
{
Identifier = uid,
Title = "XML Integration Requirement",
Description = "This requirement is retrieved in XML format"
};
strictDocService.GetAllRequirementsAsync(baseUrl).Returns(new List<Requirement> { requirement });

using var host = CreateHost(strictDocService, baseUrlService, linkSidecarService, oslcQueryService);
using var client = host.GetTestClient();

// Act
using var request = new HttpRequestMessage(HttpMethod.Get, $"/?a={uid}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/rdf+xml"));
using var response = await client.SendAsync(request);

// Assert
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();

await Assert.That(content).Contains("XML Integration Requirement");
await Assert.That(content).Contains("This requirement is retrieved in XML format");
await Assert.That(content).Contains("<rdf:RDF");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="OSLC4Net.Core" />
<PackageReference Include="OSLC4Net.Core.DotNetRdfProvider"/>
<PackageReference Include="OSLC4Net.Domains.RequirementsManagement" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="TUnit" />
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport" />
<PackageReference Include="Microsoft.Testing.Extensions.CrashDump" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@
using OSLC4Net.Core.Model;
using StrictDocOslcRm.Models;
using StrictDocOslcRm.Services;
using Compact = StrictDocOslcRm.Models.Compact;
using Preview = StrictDocOslcRm.Models.Preview;

namespace StrictDocOslcRm.Controllers;

/// <summary>
/// Controller for individual requirement access using the new URI format
/// </summary>
[ApiController]
[Produces("application/rdf+xml", "text/turtle", "application/ld+json", "application/json", "text/html")]
[Produces(
"application/rdf+xml",
"text/turtle",
"application/ld+json",
"application/json",
"text/html"
)]
public class RequirementController(
ILogger<RequirementController> logger,
IBaseUrlService baseUrlService,
IStrictDocService strictDocService) : Controller
IStrictDocService strictDocService
) : Controller
{
/// <summary>
/// Unified endpoint for requirement resources, compact resources, and HTML previews.
Expand All @@ -26,7 +31,8 @@ public class RequirementController(
public async Task<IActionResult> GetRequirementResource(
[FromQuery] string a,
[FromQuery] string? compact,
[FromQuery] string? preview)
[FromQuery] string? preview
)
{
if (string.IsNullOrEmpty(a))
{
Expand All @@ -35,7 +41,9 @@ public async Task<IActionResult> GetRequirementResource(

var baseUrl = baseUrlService.GetBaseUrl();
var allRequirements = await strictDocService.GetAllRequirementsAsync(baseUrl);
var requirement = allRequirements.FirstOrDefault(r => string.Equals(r.Identifier, a, StringComparison.Ordinal));
var requirement = allRequirements.FirstOrDefault(r =>
string.Equals(r.Identifier, a, StringComparison.Ordinal)
);

if (requirement == null)
{
Expand All @@ -51,20 +59,23 @@ public async Task<IActionResult> GetRequirementResource(
var acceptHeader = Request.Headers.Accept.ToString();
if (!acceptHeader.Contains("text/html") && !acceptHeader.Contains("*/*"))
{
return StatusCode(406, "Not Acceptable: text/html is required for preview requests");
return StatusCode(
406,
"Not Acceptable: text/html is required for preview requests"
);
}

var model = new RequirementPreviewViewModel
{
Requirement = requirement,
RequirementUri = requirementUri
RequirementUri = requirementUri,
};

return preview.ToLower() switch
{
"small" => View("SmallPreview", model),
"large" => View("LargePreview", model),
_ => BadRequest($"Invalid preview type: {preview}. Use 'small' or 'large'.")
_ => BadRequest($"Invalid preview type: {preview}. Use 'small' or 'large'."),
};
}

Expand All @@ -79,56 +90,63 @@ public async Task<IActionResult> GetRequirementResource(
var accept = Request.Headers.Accept.ToString();
// Add Vary header for proper caching with negotiation
Response.Headers.Vary = "Accept";
var wantsJson = string.IsNullOrWhiteSpace(accept)
|| accept.Contains("application/json", StringComparison.OrdinalIgnoreCase);
var prefersRdf = accept.Contains("text/turtle", StringComparison.OrdinalIgnoreCase)
|| accept.Contains("application/ld+json", StringComparison.OrdinalIgnoreCase)
|| accept.Contains("application/rdf+xml", StringComparison.OrdinalIgnoreCase);
var wantsJson =
string.IsNullOrWhiteSpace(accept)
|| accept.Contains("application/json", StringComparison.OrdinalIgnoreCase);
var prefersRdf =
accept.Contains("text/turtle", StringComparison.OrdinalIgnoreCase)
|| accept.Contains("application/ld+json", StringComparison.OrdinalIgnoreCase)
|| accept.Contains("application/rdf+xml", StringComparison.OrdinalIgnoreCase);

// If client explicitly requests only text/html for compact, return 406
var acceptsHtmlOnly = !string.IsNullOrWhiteSpace(accept)
&& accept.Contains("text/html", StringComparison.OrdinalIgnoreCase)
&& !wantsJson
&& !prefersRdf
&& !accept.Contains("*/*", StringComparison.OrdinalIgnoreCase);
var acceptsHtmlOnly =
!string.IsNullOrWhiteSpace(accept)
&& accept.Contains("text/html", StringComparison.OrdinalIgnoreCase)
&& !wantsJson
&& !prefersRdf
&& !accept.Contains("*/*", StringComparison.OrdinalIgnoreCase);
if (acceptsHtmlOnly)
{
return StatusCode(406, "Not Acceptable: compact representation is not text/html. Use Accept: application/json or an RDF type.");
return StatusCode(
406,
"Not Acceptable: compact representation is not text/html. Use Accept: application/json or an RDF type."
);
}

if (wantsJson && !prefersRdf)
{
logger.LogDebug("Client prefers plain JSON for compact resource, returning OSLC 3.0 Compact JSON shape");
logger.LogDebug(
"Client prefers plain JSON for compact resource, returning OSLC 3.0 Compact JSON shape"
);

var compactDto = new
var compactDto = new CompactDto
{
title = requirement.Title ?? requirement.Identifier,
shortTitle = requirement.Identifier,
icon = iconUri,
iconTitle = "Requirement",
iconAltLabel = "Requirement",
smallPreview = new
Title = requirement.Title ?? requirement.Identifier,
ShortTitle = requirement.Identifier,
Icon = iconUri,
IconTitle = "Requirement",
IconAltLabel = "Requirement",
SmallPreview = new PreviewDto
{
document = smallDoc,
hintWidth = "320px",
hintHeight = "200px"
Document = smallDoc,
HintWidth = "320px",
HintHeight = "200px",
},
largePreview = new
LargePreview = new PreviewDto
{
document = largeDoc,
hintWidth = "600px",
hintHeight = "400px"
}
Document = largeDoc,
HintWidth = "600px",
HintHeight = "400px",
},
};

return new JsonResult(compactDto);
}

// Otherwise, return RDF/LD-friendly Compact resource
var compactResource = new Compact();
compactResource.SetAbout(new Uri($"{requirementUri}&compact"));
compactResource.Title = requirement.Title ?? requirement.Identifier;
compactResource.ShortTitle = requirement.Identifier;
var compactResource = new Compact(new Uri($"{requirementUri}&compact"));
compactResource.Title = requirement.Title ?? requirement.Identifier ?? "";
compactResource.ShortTitle = requirement.Identifier ?? "";
compactResource.Icon = new Uri(iconUri);
compactResource.IconTitle = "Requirement";
compactResource.IconAltLabel = "Requirement";
Expand All @@ -137,14 +155,14 @@ public async Task<IActionResult> GetRequirementResource(
{
Document = new Uri(smallDoc),
HintWidth = "320px",
HintHeight = "200px"
HintHeight = "200px",
};

compactResource.LargePreview = new Preview
{
Document = new Uri(largeDoc),
HintWidth = "600px",
HintHeight = "400px"
HintHeight = "400px",
};

return Ok(compactResource);
Expand All @@ -154,8 +172,10 @@ public async Task<IActionResult> GetRequirementResource(
requirement.SetAbout(new Uri(requirementUri));

// Add Link header for Compact resource (OSLC Resource Preview spec)
Response.Headers.Append("Link",
$"<{requirementUri}&compact>; rel=\"{OslcConstants.OSLC_CORE_NAMESPACE}Compact\"");
Response.Headers.Append(
"Link",
$"<{requirementUri}&compact>; rel=\"{OslcConstants.OSLC_CORE_NAMESPACE}Compact\""
);

return Ok(requirement);
}
Expand Down
Loading
Loading