├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── RiskFirst.Hateoas.sln ├── Samples ├── RiskFirst.Hateoas.BasicSample │ ├── src │ │ └── RiskFirst.Hateoas.BasicSample │ │ │ ├── Controllers │ │ │ └── ValuesController.cs │ │ │ ├── Models │ │ │ └── ValueInfo.cs │ │ │ ├── Program.cs │ │ │ ├── Properties │ │ │ └── launchSettings.json │ │ │ ├── Repository │ │ │ └── ValuesRepository.cs │ │ │ ├── RiskFirst.Hateoas.BasicSample.csproj │ │ │ ├── appsettings.Development.json │ │ │ └── appsettings.json │ └── tests │ │ └── RiskFirst.Hateoas.BasicSample.Tests │ │ ├── LinksTests.cs │ │ ├── RiskFirst.Hateoas.BasicSample.Tests.csproj │ │ └── Usings.cs ├── RiskFirst.Hateoas.CustomRequirementSample │ ├── src │ │ └── RiskFirst.Hateoas.CustomRequirementSample │ │ │ ├── Controllers │ │ │ ├── RootController.cs │ │ │ └── ValuesController.cs │ │ │ ├── LinksPolicyBuiderExtensions.cs │ │ │ ├── Models │ │ │ ├── ApiInfo.cs │ │ │ └── ValueInfo.cs │ │ │ ├── Program.cs │ │ │ ├── Properties │ │ │ └── launchSettings.json │ │ │ ├── Repository │ │ │ └── ValuesRepository.cs │ │ │ ├── Requirement │ │ │ ├── ApiRootLinkHandler.cs │ │ │ └── ApiRootLinkRequirement.cs │ │ │ ├── RiskFirst.Hateoas.CustomRequirementSample.csproj │ │ │ ├── appsettings.Development.json │ │ │ └── appsettings.json │ └── tests │ │ └── RiskFirst.Hateoas.CustomRequirementsSample.Tests │ │ ├── LinkEqualityComparer.cs │ │ ├── LinkTests.cs │ │ ├── RiskFirst.Hateoas.CustomRequirementsSample.Tests.csproj │ │ └── Usings.cs └── RiskFirst.Hateoas.LinkConfigurationSample │ └── src │ └── RiskFirst.Hateoas.LinkConfigurationSample │ ├── Controllers │ ├── ModelsController.cs │ └── ValuesController.cs │ ├── Extensions │ ├── LinkTransformationBuilderExtensions.cs │ └── ModelRelTransformation.cs │ ├── Models │ └── ValueInfo.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Repository │ └── ValuesRepository.cs │ ├── RiskFirst.Hateoas.LinkConfigurationSample.csproj │ ├── appsettings.Development.json │ └── appsettings.json ├── src ├── RiskFirst.Hateoas.Models │ ├── ILinkContainer.cs │ ├── IPagedLinkContainer.cs │ ├── ItemsLinkContainer.cs │ ├── Link.cs │ ├── LinkCollection.cs │ ├── LinkCollectionConverter.cs │ ├── LinkContainer.cs │ ├── PagedItemsLinkContainer.cs │ └── RiskFirst.Hateoas.Models.csproj └── RiskFirst.Hateoas │ ├── BuilderLinkTransformation.cs │ ├── DefaultLinkAuthorizationService.cs │ ├── DefaultLinkTransformationContextFactory.cs │ ├── DefaultLinksEvaluator.cs │ ├── DefaultLinksHandlerContextFactory.cs │ ├── DefaultLinksPolicyProvider.cs │ ├── DefaultLinksService.cs │ ├── DefaultRouteMap.cs │ ├── IControllerMethodInfo.cs │ ├── ILinkAuthorizationService.cs │ ├── ILinkTransformation.cs │ ├── ILinkTransformationContextFactory.cs │ ├── ILinksEvaluator.cs │ ├── ILinksHandler.cs │ ├── ILinksHandlerContextFactory.cs │ ├── ILinksPolicy.cs │ ├── ILinksPolicyProvider.cs │ ├── ILinksRequirement.cs │ ├── ILinksService.cs │ ├── ILinksServiceExtensions.cs │ ├── IRouteMap.cs │ ├── Implementation │ ├── PagingLinksRequirement.cs │ ├── PassThroughLinksHandler.cs │ ├── RouteLinkRequirement.cs │ └── SelfLinkRequirement.cs │ ├── LinkAuthorizationContext.cs │ ├── LinkCondition.cs │ ├── LinkConditionBuilder.cs │ ├── LinkSpec.cs │ ├── LinkTransformationBuilder.cs │ ├── LinkTransformationBuilderExtensions.cs │ ├── LinkTransformationContext.cs │ ├── LinkTransformationException.cs │ ├── LinksAttribute.cs │ ├── LinksHandler.cs │ ├── LinksHandlerContext.cs │ ├── LinksOptions.cs │ ├── LinksPolicy.cs │ ├── LinksPolicyBuilder.cs │ ├── LinksPolicyBuilderExtensions.cs │ ├── LinksServicesCollectionExtensions.cs │ ├── Polyfills │ ├── DefaultAssemblyLoader.cs │ └── IAssemblyLoader.cs │ ├── Properties │ └── AssemblyInfo.cs │ ├── ReflectionControllerMethodInfo.cs │ ├── RiskFirst.Hateoas.csproj │ └── RouteInfo.cs └── tests └── RiskFirst.Hateoas.Tests ├── Controllers ├── ApiController.cs ├── Models │ └── ValueInfo.cs └── MvcController.cs ├── DefaultLinkAuthorizationServiceTests.cs ├── DefaultLinkServiceTests.cs ├── DefaultLinksEvaluatorTests.cs ├── DefaultRouteMapTests.cs ├── Infrastructure ├── Attributes.cs ├── TestCaseBuilder.cs ├── TestCases.cs ├── TestLinksHandlerContextFactory.cs └── TestRouteMap.cs ├── JsonSerializationTests.cs ├── LinksOptionsTests.cs ├── Polyfills └── DefaultAssemblyLoaderTests.cs ├── RiskFirst.Hateoas.Tests.csproj ├── TestLinkContainers.cs ├── TestRequirement.cs ├── TestRequirementHandler.cs └── XmlSerizalizationTests.cs /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | container: 9 | image: mcr.microsoft.com/dotnet/sdk:7.0 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Install dependencies 14 | run: dotnet restore 15 | - name: Build 16 | run: dotnet build --configuration Release --no-restore 17 | - name: Test 18 | run: dotnet test --no-restore --verbosity normal 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.*~ 3 | project.lock.json 4 | .DS_Store 5 | *.pyc 6 | nupkg/ 7 | 8 | # Visual Studio Code 9 | .vscode 10 | 11 | # Rider 12 | .idea 13 | 14 | # User-specific files 15 | *.suo 16 | *.user 17 | *.userosscache 18 | *.sln.docstates 19 | 20 | # Build results 21 | [Dd]ebug/ 22 | [Dd]ebugPublic/ 23 | [Rr]elease/ 24 | [Rr]eleases/ 25 | x64/ 26 | x86/ 27 | build/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Oo]ut/ 32 | msbuild.log 33 | msbuild.err 34 | msbuild.wrn 35 | 36 | # Visual Studio 2015 37 | .vs/ 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 riskfirst 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RiskFirst.Hateoas 2 | 3 | [![CI Build](https://github.com/riskfirst/riskfirst.hateoas/actions/workflows/build.yml/badge.svg)](https://github.com/riskfirst/riskfirst.hateoas/actions/workflows/build.yml) 4 | 5 | An implementation of [HATEOAS](https://en.wikipedia.org/wiki/HATEOAS) for aspnet core web api projects which gives full control of which links to apply to models returned from your api. In order to communicate varying state to the end-user, this library fully integrates with Authorization, and allows arbitrary conditions to determine whether to show or hide HATEOAS links between api resources. 6 | 7 | ### Getting started 8 | 9 | Install the package from [Nuget.org](https://www.nuget.org/packages/riskfirst.hateoas) 10 | 11 | ```powershell 12 | PM> Install-Package RiskFirst.Hateoas 13 | ``` 14 | 15 | This will include the dependency RiskFirst.Hateoas.Models which was introduced in version 3.0.0 to remove the AspNetCore dependencies from assemblies referencing the LinkContainer base classes. 16 | 17 | Configure the links to include for each of your models. 18 | 19 | ```csharp 20 | public class Startup 21 | { 22 | public void ConfigureServices(IServicesCollection services) 23 | { 24 | services.AddLinks(config => 25 | { 26 | config.AddPolicy(policy => { 27 | policy.RequireSelfLink() 28 | .RequireRoutedLink("all", "GetAllModelsRoute") 29 | .RequireRoutedLink("delete", "DeleteModelRoute", x => new { id = x.Id }); 30 | }); 31 | }); 32 | } 33 | } 34 | ``` 35 | 36 | Inject `ILinksService` into any controller (or other class in your project) to add links to a model. 37 | 38 | ```csharp 39 | [Route("api/[controller]")] 40 | public class MyController : Controller 41 | { 42 | private readonly ILinksService linksService; 43 | 44 | public MyController(ILinksService linksService) 45 | { 46 | this.linksService = linksService; 47 | } 48 | 49 | [HttpGet("{id}",Name = "GetModelRoute")] 50 | public async Task GetMyModel(int id) 51 | { 52 | var model = await myRepository.GetMyModel(id); 53 | await linksService.AddLinksAsync(model); 54 | return model; 55 | } 56 | [HttpGet(Name="GetAllModelsRoute")] 57 | public async Task> GetAllModels() 58 | { 59 | //... snip .. // 60 | } 61 | 62 | [HttpDelete("{id}",Name = "DeleteModelRoute")] 63 | public async Task DeleteMyModel(int id) 64 | { 65 | //... snip .. // 66 | } 67 | } 68 | ``` 69 | 70 | The above code would produce a response as the example below 71 | 72 | ```json 73 | { 74 | "id": 1, 75 | "someOtherField": "foo", 76 | "_links": { 77 | "self": { 78 | "rel": "MyController\\GetModelRoute", 79 | "href": "https://api.example.com/my/1", 80 | "method": "GET" 81 | }, 82 | "all": { 83 | "rel": "MyController\\GetAllModelsRoute", 84 | "href": "https://api.example.com/my", 85 | "method": "GET" 86 | }, 87 | "delete": { 88 | "rel": "MyController\\DeleteModelRoute", 89 | "href": "https://api.example.com/my/1", 90 | "method": "DELETE" 91 | } 92 | } 93 | } 94 | ``` 95 | 96 | or if you're using XML 97 | 98 | ```xml 99 | 100 | 101 | 102 | 103 | 104 | 1 105 | foo 106 | 107 | ``` 108 | 109 | ### Multiple policies for a model 110 | 111 | It is possible to specify multiple named policies for a model during startup by providing a policy name to `AddPolicy`. For example, you could have the default (unnamed) policy give basic links when the model is part of a list, but more detailed information when a model is returned alone. 112 | 113 | ```csharp 114 | public class Startup 115 | { 116 | public void ConfigureServices(IServicesCollection services) 117 | { 118 | services.AddLinks(config => 119 | { 120 | config.AddPolicy(policy => { 121 | policy.RequireRoutedLink("self","GetModelRoute", x => new {id = x.Id }) 122 | }); 123 | 124 | config.AddPolicy("FullInfo",policy => { 125 | policy.RequireSelfLink() 126 | .RequireRoutedLink("all", "GetAllModelsRoute") 127 | .RequireRoutedLink("parentModels", "GetParentModelRoute", x => new { parentId = x.ParentId }); 128 | .RequireRoutedLink("subModels", "GetSubModelsRoute", x => new { id = x.Id }); 129 | .RequireRoutedLink("delete", "DeleteModelRoute", x => new { id = x.Id }); 130 | }); 131 | }); 132 | } 133 | } 134 | ``` 135 | 136 | With a named policy, this can be applied at runtime using an overload of `AddLinksAsync` which takes a policy name: 137 | 138 | ```csharp 139 | await linksService.AddLinksAsync(model,"FullInfo"); 140 | ``` 141 | 142 | You can also markup your controller method with a `LinksAttribute` to override the default policy applied. The below code would apply the "FullInfo" profile to the returned model without having to specify the policy name in the call to `AddLinksAsync`. 143 | 144 | ```csharp 145 | [Route("api/[controller]")] 146 | public class MyController : Controller 147 | { 148 | private readonly ILinksService linksService; 149 | 150 | public MyController(ILinksService linksService) 151 | { 152 | this.linksService = linksService; 153 | } 154 | 155 | [HttpGet("{id}",Name = "GetModelRoute")] 156 | [Links(Policy = "FullInfo")] 157 | public async Task GetMyModel(int id) 158 | { 159 | var model = await myRepository.GetMyModel(id); 160 | await linksService.AddLinksAsync(model); 161 | return model; 162 | } 163 | } 164 | ``` 165 | 166 | Another way to achieve the same thing is to mark the actual object with the `LinksAttribute`: 167 | 168 | ```csharp 169 | [Links(Policy="FullInfo")] 170 | public class MyModel : LinkContainer 171 | { } 172 | 173 | [Route("api/[controller]")] 174 | public class MyController : Controller 175 | { 176 | private readonly ILinksService linksService; 177 | 178 | public MyController(ILinksService linksService) 179 | { 180 | this.linksService = linksService; 181 | } 182 | 183 | [HttpGet("{id}",Name = "GetModelRoute")] 184 | public async Task GetMyModel(int id) 185 | { 186 | MyModel model = await myRepository.GetMyModel(id); 187 | await linksService.AddLinksAsync(model); 188 | return model; 189 | } 190 | } 191 | ``` 192 | 193 | There are further overloads of `AddLinksAsync` which take an instance of [`ILinksPolicy`](src/RiskFirst.Hateoas/ILinksPolicy.cs) or an array of [`ILinksRequirement`](src/RiskFirst.Hateoas/ILinksRequirement.cs) which will be evaluated at runtime. This should give complete control of which links are applied at any point within your api code. 194 | 195 | ### Configuring Href and Rel transformations 196 | 197 | There should not have much need to change how the `Href` is transformed, however one common requirement is to output relative instead of absolute uris. This can be tried in the [Basic Sample](Samples/RiskFirst.Hateoas.BasicSample) 198 | 199 | ```csharp 200 | services.AddLinks(config => 201 | { 202 | config.UseRelativeHrefs(); 203 | ... 204 | }); 205 | ``` 206 | 207 | Both Href and Rel transformations can be fully controlled by supplying a class or Type which implements [`ILinkTransformation`](src/RiskFirst.Hateoas/ILinkTransformation.cs). 208 | 209 | ```csharp 210 | services.AddLinks(config => 211 | { 212 | // supply a type implementing ILinkTransformation 213 | config.UseHrefTransformation(); 214 | // or supply an instance 215 | config.UseRelTransformation(new MyRelTransformation()); 216 | }); 217 | ``` 218 | 219 | Alternatively, transformations can be configured using a builder syntax 220 | 221 | ```csharp 222 | services.AddLinks(config => 223 | { 224 | // output a uri for the rel values 225 | config.ConfigureRelTransformation(transform => transform.AddProtocol() 226 | .AddHost() 227 | .AddVirtualPath(ctx => $"/rel/{ctx.LinkSpec.ControllerName}/{ctx.LinkSpec.RouteName}"); 228 | }); 229 | ``` 230 | 231 | Both ways of customizaing transformations can be seen in the [LinkConfigurationSample](samples/RiskFirst.Hateoas.LinkConfigurationSample). 232 | 233 | ### Authorization and Conditional links 234 | 235 | It is likely that you wish to control which links are included with each model, and one common requirement is to only show links for which the current user is authorized. This library fully integrates into the authorization pipeline and will apply any authorization policy you have applied to the linked action. 236 | 237 | To enable authorization on a link provide the `AuthorizeRoute` condition. 238 | 239 | ```csharp 240 | public class Startup 241 | { 242 | public void ConfigureServices(IServicesCollection services) 243 | { 244 | services.AddLinks(config => 245 | { 246 | config.AddPolicy("FullInfo",policy => { 247 | policy.RequireSelfLink() 248 | .RequireRoutedLink("all", "GetAllModelsRoute") 249 | .RequireRoutedLink("parentModels", "GetParentModelRoute", 250 | x => new { parentId = x.ParentId }, condition => condition.AuthorizeRoute()); 251 | .RequireRoutedLink("subModels", "GetSubModelsRoute", 252 | x => new { id = x.Id }, condition => condition.AuthorizeRoute()); 253 | .RequireRoutedLink("delete", "DeleteModelRoute", 254 | x => new { id = x.Id }, condition => condition.AuthorizeRoute()); 255 | }); 256 | }); 257 | } 258 | } 259 | ``` 260 | 261 | In the above example, `GetParentModelRoute`, `GetSubModelsRoute` & `DeleteModelRoute` will not be shown to a user who does not have access to those routes as defined by their authorization policies. See the [Microsoft documentation](https://docs.microsoft.com/en-us/aspnet/core/security/authorization/) for more information on authrization within an aspnet core webapi project. 262 | 263 | As with the above examples, there are further condition methods which allow you to specifiy a policy name, an absolute policy or a set of requirements. 264 | 265 | You can also conditionally show a link based on any boolean logic by using the `Assert` condition. For example, there is a method which allows you to add common paging links to paged results of objects. You may decide these are not worthwhile if there is a total of only one page of results. 266 | 267 | ```csharp 268 | options.AddPolicy(policy => 269 | { 270 | policy.RequireelfLink("all") 271 | .RequirePagingLinks(condition => condition.Assert(x => x.PageCount > 1 )); 272 | }); 273 | ``` 274 | 275 | ### Further customization 276 | 277 | You are free to add your own requirements using the generic `Requires` method on `LinksPolicyBuilder`. In addition, you must write an implementation of `ILinksHandler` to handle your requirement. For example, you may have a requirement on certain responses to provide a link back to your api root document. Define a simple requirement for this link. 278 | 279 | ```csharp 280 | using RiskFirst.Hateoas; 281 | 282 | public class ApiRootLinkRequirement : ILinksRequirement 283 | { 284 | public ApiRootLinkRequirement() 285 | { 286 | } 287 | public string Id { get; set; } = "root"; 288 | } 289 | ``` 290 | 291 | Given this requirement, we need a class to handle it, which must implement `ILinkHandler` and handle your requirement. 292 | 293 | ```csharp 294 | using RiskFirst.Hateoas; 295 | 296 | public class ApiRootLinkHandler : LinksHandler 297 | { 298 | protected override Task HandleRequirementAsync(LinksHandlerContext context, ApiRootLinkRequirement requirement) 299 | { 300 | var route = context.RouteMap.GetRoute("ApiRoot"); // Assumes your API has a named route "ApiRoot". 301 | context.Links.Add(new LinkSpec(requirement.Id, route)); 302 | context.Handled(requirement); 303 | return Task.CompletedTask; 304 | } 305 | } 306 | ``` 307 | 308 | Finally register your Handler with `IServicesCollection` and use the requirement within your link policy 309 | 310 | ```csharp 311 | public class Startup 312 | { 313 | public void ConfigureServices(IServicesCollection services) 314 | { 315 | services.AddLinks(config => 316 | { 317 | config.AddPolicy(policy => 318 | { 319 | policy.RequireRoutedLink("self","GetModelRoute", x => new {id = x.Id }) 320 | .Requires(); 321 | }); 322 | }); 323 | 324 | services.AddTransient(); 325 | } 326 | } 327 | ``` 328 | 329 | This example is demonstrated in the [`CustomRequirementSample`](samples/RiskFirst.Hateoas.CustomRequirementSample) 330 | 331 | There are many additional parts of the framework which can be extended by writing your own implementation of the appropriate interface and registering it with `IServicesCollection` for dependency injection. For example, you could change the way that links are evaluated and applied to your link container by implementing your own [`ILinksEvaluator`](src/RiskFirst.Hateoas/ILinksEvaluator.cs) 332 | 333 | ```csharp 334 | using RiskFirst.Hateoas; 335 | 336 | public class Startup 337 | { 338 | public void ConfigureServices(IServicesCollection services) 339 | { 340 | services.AddLinks(options => { 341 | ... 342 | }); 343 | services.AddTransient(); 344 | } 345 | } 346 | ``` 347 | 348 | The list of interfaces which have a default implementation, but which can be replaced is: 349 | 350 | - [`ILinkAuthorizationService`](src/RiskFirst.Hateoas/ILinkAuthorizationService.cs), 351 | controls how links are authorized during link condition evaluation. 352 | - [`ILinksEvaluator`](src/RiskFirst.Hateoas/ILinksEvaluator.cs), controls how links are evaluated and transformed before being written to the returned model. 353 | - [`ILinksHandlerContextFactory`](src/RiskFirst.Hateoas/ILinksHandlerContextFactory.cs), controls how the context is created which is passed through the requirement handlers during processing. 354 | - [`ILinksPolicyProvider`](src/RiskFirst.Hateoas/ILinksPolicyProvider.cs), provides lookup for `ILinkPolicy` instances by resource type and name. 355 | - [`ILinksService`](src/RiskFirst.Hateoas/ILinksService.cs), the main entrypoint into the framework, this interface is injected into user code to apply links to api resources. 356 | - [`ILinkTransformationContextFactory`](src/RiskFirst.Hateoas/ILinkTransformationContextFactory.cs), controls how the transformation context is created during transformation for rel & href properies of links. 357 | - [`IRouteMap`](src/RiskFirst.Hateoas/IRouteMap.cs), controls how your API is indexed to allow links between routes. 358 | 359 | ### Troubleshooting 360 | 361 | #### Upgrading from v1.0.x to v1.1.x 362 | 363 | The change from version 1.0.x to 1.1.x was mostly non-breaking, however if you have implemented any custom requirement handlers as described in the example above the signature of the base class `LinksHandler` changed slightly to remove the duplicate declaration of the generic type `TResource`. 364 | 365 | In v1.0.x your code may have looked like: 366 | 367 | ```csharp 368 | public class MyCustomHandler : ILinksHandler { ... } 369 | ``` 370 | 371 | It should now inherit from `LinksHandler` making implementation simpler, and giving a type-safe override of `HandleRequirementAsync` giving access to your correctly-typed requirement. 372 | 373 | ```csharp 374 | public class MyCustomHandler : LinksHandler 375 | ``` 376 | -------------------------------------------------------------------------------- /RiskFirst.Hateoas.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8FDAAE59-BA72-4DCA-A282-E4B4BEB0B0AE}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas", "src\RiskFirst.Hateoas\RiskFirst.Hateoas.csproj", "{1AD05FF4-3582-4800-B2B9-23086FE73B3F}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas.Models", "src\RiskFirst.Hateoas.Models\RiskFirst.Hateoas.Models.csproj", "{AEBAF462-70EF-491C-BB4A-43E7311F6E07}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{57378CE4-8C7D-4AEB-ADFE-0B368A88427C}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas.Tests", "tests\RiskFirst.Hateoas.Tests\RiskFirst.Hateoas.Tests.csproj", "{DD084CBB-FB1B-4039-80B4-F96A91598C66}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{F0D4045E-E353-4846-A804-D001DD3722D6}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RiskFirst.Hateoas.BasicSample", "RiskFirst.Hateoas.BasicSample", "{54A5DD38-FE7C-47E7-8D01-631BCCB04E35}" 19 | EndProject 20 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0B1F7A23-D045-4E38-961F-E69CD2C95785}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas.BasicSample", "Samples\RiskFirst.Hateoas.BasicSample\src\RiskFirst.Hateoas.BasicSample\RiskFirst.Hateoas.BasicSample.csproj", "{72EC5410-E396-4CA6-B843-A1C6609E36C7}" 23 | EndProject 24 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{8BC116FF-0599-4C88-BBE8-22EA9E742770}" 25 | EndProject 26 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas.BasicSample.Tests", "Samples\RiskFirst.Hateoas.BasicSample\tests\RiskFirst.Hateoas.BasicSample.Tests\RiskFirst.Hateoas.BasicSample.Tests.csproj", "{81B60D8E-EA09-4784-B221-035532D85040}" 27 | EndProject 28 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RiskFirst.Hateoas.CustomRequirementSample", "RiskFirst.Hateoas.CustomRequirementSample", "{1B236E21-1F97-4D6D-BCA2-4BDA41D64CF1}" 29 | EndProject 30 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4018B988-CCA3-4AE7-A3B9-F66D0D043D7C}" 31 | EndProject 32 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas.CustomRequirementSample", "Samples\RiskFirst.Hateoas.CustomRequirementSample\src\RiskFirst.Hateoas.CustomRequirementSample\RiskFirst.Hateoas.CustomRequirementSample.csproj", "{1F72BDAA-9301-4411-8697-F3B7E71EF604}" 33 | EndProject 34 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RiskFirst.Hateoas.LinkConfigurationSample", "RiskFirst.Hateoas.LinkConfigurationSample", "{A65117F5-5B86-4456-A4B3-CF2987AB092C}" 35 | EndProject 36 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BFFEAFB5-6C6E-48B6-804B-554EB4870514}" 37 | EndProject 38 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas.LinkConfigurationSample", "Samples\RiskFirst.Hateoas.LinkConfigurationSample\src\RiskFirst.Hateoas.LinkConfigurationSample\RiskFirst.Hateoas.LinkConfigurationSample.csproj", "{2E1C7F7C-3370-498F-A791-7C77890A4A88}" 39 | EndProject 40 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4F912239-9425-4F8E-B6F7-0EDF99EBDA7E}" 41 | EndProject 42 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RiskFirst.Hateoas.CustomRequirementsSample.Tests", "Samples\RiskFirst.Hateoas.CustomRequirementSample\tests\RiskFirst.Hateoas.CustomRequirementsSample.Tests\RiskFirst.Hateoas.CustomRequirementsSample.Tests.csproj", "{9907CDFB-8BBE-465B-B4B7-4B84D807DAD3}" 43 | EndProject 44 | Global 45 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 46 | Debug|Any CPU = Debug|Any CPU 47 | Release|Any CPU = Release|Any CPU 48 | EndGlobalSection 49 | GlobalSection(SolutionProperties) = preSolution 50 | HideSolutionNode = FALSE 51 | EndGlobalSection 52 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 53 | {1AD05FF4-3582-4800-B2B9-23086FE73B3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 54 | {1AD05FF4-3582-4800-B2B9-23086FE73B3F}.Debug|Any CPU.Build.0 = Debug|Any CPU 55 | {1AD05FF4-3582-4800-B2B9-23086FE73B3F}.Release|Any CPU.ActiveCfg = Release|Any CPU 56 | {1AD05FF4-3582-4800-B2B9-23086FE73B3F}.Release|Any CPU.Build.0 = Release|Any CPU 57 | {AEBAF462-70EF-491C-BB4A-43E7311F6E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 58 | {AEBAF462-70EF-491C-BB4A-43E7311F6E07}.Debug|Any CPU.Build.0 = Debug|Any CPU 59 | {AEBAF462-70EF-491C-BB4A-43E7311F6E07}.Release|Any CPU.ActiveCfg = Release|Any CPU 60 | {AEBAF462-70EF-491C-BB4A-43E7311F6E07}.Release|Any CPU.Build.0 = Release|Any CPU 61 | {DD084CBB-FB1B-4039-80B4-F96A91598C66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 62 | {DD084CBB-FB1B-4039-80B4-F96A91598C66}.Debug|Any CPU.Build.0 = Debug|Any CPU 63 | {DD084CBB-FB1B-4039-80B4-F96A91598C66}.Release|Any CPU.ActiveCfg = Release|Any CPU 64 | {DD084CBB-FB1B-4039-80B4-F96A91598C66}.Release|Any CPU.Build.0 = Release|Any CPU 65 | {72EC5410-E396-4CA6-B843-A1C6609E36C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 66 | {72EC5410-E396-4CA6-B843-A1C6609E36C7}.Debug|Any CPU.Build.0 = Debug|Any CPU 67 | {72EC5410-E396-4CA6-B843-A1C6609E36C7}.Release|Any CPU.ActiveCfg = Release|Any CPU 68 | {72EC5410-E396-4CA6-B843-A1C6609E36C7}.Release|Any CPU.Build.0 = Release|Any CPU 69 | {81B60D8E-EA09-4784-B221-035532D85040}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 70 | {81B60D8E-EA09-4784-B221-035532D85040}.Debug|Any CPU.Build.0 = Debug|Any CPU 71 | {81B60D8E-EA09-4784-B221-035532D85040}.Release|Any CPU.ActiveCfg = Release|Any CPU 72 | {81B60D8E-EA09-4784-B221-035532D85040}.Release|Any CPU.Build.0 = Release|Any CPU 73 | {1F72BDAA-9301-4411-8697-F3B7E71EF604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 74 | {1F72BDAA-9301-4411-8697-F3B7E71EF604}.Debug|Any CPU.Build.0 = Debug|Any CPU 75 | {1F72BDAA-9301-4411-8697-F3B7E71EF604}.Release|Any CPU.ActiveCfg = Release|Any CPU 76 | {1F72BDAA-9301-4411-8697-F3B7E71EF604}.Release|Any CPU.Build.0 = Release|Any CPU 77 | {2E1C7F7C-3370-498F-A791-7C77890A4A88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 78 | {2E1C7F7C-3370-498F-A791-7C77890A4A88}.Debug|Any CPU.Build.0 = Debug|Any CPU 79 | {2E1C7F7C-3370-498F-A791-7C77890A4A88}.Release|Any CPU.ActiveCfg = Release|Any CPU 80 | {2E1C7F7C-3370-498F-A791-7C77890A4A88}.Release|Any CPU.Build.0 = Release|Any CPU 81 | {9907CDFB-8BBE-465B-B4B7-4B84D807DAD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 82 | {9907CDFB-8BBE-465B-B4B7-4B84D807DAD3}.Debug|Any CPU.Build.0 = Debug|Any CPU 83 | {9907CDFB-8BBE-465B-B4B7-4B84D807DAD3}.Release|Any CPU.ActiveCfg = Release|Any CPU 84 | {9907CDFB-8BBE-465B-B4B7-4B84D807DAD3}.Release|Any CPU.Build.0 = Release|Any CPU 85 | EndGlobalSection 86 | GlobalSection(NestedProjects) = preSolution 87 | {1AD05FF4-3582-4800-B2B9-23086FE73B3F} = {8FDAAE59-BA72-4DCA-A282-E4B4BEB0B0AE} 88 | {AEBAF462-70EF-491C-BB4A-43E7311F6E07} = {8FDAAE59-BA72-4DCA-A282-E4B4BEB0B0AE} 89 | {DD084CBB-FB1B-4039-80B4-F96A91598C66} = {57378CE4-8C7D-4AEB-ADFE-0B368A88427C} 90 | {54A5DD38-FE7C-47E7-8D01-631BCCB04E35} = {F0D4045E-E353-4846-A804-D001DD3722D6} 91 | {0B1F7A23-D045-4E38-961F-E69CD2C95785} = {54A5DD38-FE7C-47E7-8D01-631BCCB04E35} 92 | {72EC5410-E396-4CA6-B843-A1C6609E36C7} = {0B1F7A23-D045-4E38-961F-E69CD2C95785} 93 | {8BC116FF-0599-4C88-BBE8-22EA9E742770} = {54A5DD38-FE7C-47E7-8D01-631BCCB04E35} 94 | {81B60D8E-EA09-4784-B221-035532D85040} = {8BC116FF-0599-4C88-BBE8-22EA9E742770} 95 | {1B236E21-1F97-4D6D-BCA2-4BDA41D64CF1} = {F0D4045E-E353-4846-A804-D001DD3722D6} 96 | {4018B988-CCA3-4AE7-A3B9-F66D0D043D7C} = {1B236E21-1F97-4D6D-BCA2-4BDA41D64CF1} 97 | {1F72BDAA-9301-4411-8697-F3B7E71EF604} = {4018B988-CCA3-4AE7-A3B9-F66D0D043D7C} 98 | {A65117F5-5B86-4456-A4B3-CF2987AB092C} = {F0D4045E-E353-4846-A804-D001DD3722D6} 99 | {BFFEAFB5-6C6E-48B6-804B-554EB4870514} = {A65117F5-5B86-4456-A4B3-CF2987AB092C} 100 | {2E1C7F7C-3370-498F-A791-7C77890A4A88} = {BFFEAFB5-6C6E-48B6-804B-554EB4870514} 101 | {4F912239-9425-4F8E-B6F7-0EDF99EBDA7E} = {1B236E21-1F97-4D6D-BCA2-4BDA41D64CF1} 102 | {9907CDFB-8BBE-465B-B4B7-4B84D807DAD3} = {4F912239-9425-4F8E-B6F7-0EDF99EBDA7E} 103 | EndGlobalSection 104 | EndGlobal 105 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/Controllers/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using RiskFirst.Hateoas.BasicSample.Models; 3 | using RiskFirst.Hateoas.BasicSample.Repository; 4 | using RiskFirst.Hateoas.Models; 5 | 6 | namespace RiskFirst.Hateoas.BasicSample.Controllers; 7 | 8 | [ApiController] 9 | [Route("api/[controller]")] 10 | public class ValuesController : ControllerBase 11 | { 12 | private readonly IValuesRepository repo; 13 | private readonly ILinksService linksService; 14 | 15 | public ValuesController(IValuesRepository repo, ILinksService linksService) 16 | { 17 | this.repo = repo; 18 | this.linksService = linksService; 19 | } 20 | 21 | [HttpGet(Name = "GetAllValuesRoute")] 22 | public async Task> Get() 23 | { 24 | var values = await GetAllValuesWithLinksAsync(); 25 | 26 | var result = new ItemsLinkContainer() 27 | { 28 | Items = values 29 | }; 30 | await linksService.AddLinksAsync(result); 31 | return result; 32 | } 33 | 34 | // GET api/values/5 35 | [HttpGet("{id}", Name = "GetValueByIdRoute")] 36 | [HttpGet("v2/{id}", Name = "GetValueByIdRouteV2")] 37 | [Links(Policy = "FullInfoPolicy")] 38 | public async Task Get(int id) 39 | { 40 | var value = await repo.GetValueAsync(id); 41 | await linksService.AddLinksAsync(value); 42 | return value; 43 | } 44 | 45 | // POST api/values 46 | [HttpPost(Name = "InsertValueRoute")] 47 | public void Post([FromBody] string value) 48 | { 49 | } 50 | 51 | // PUT api/values/5 52 | [HttpPut("{id}", Name = "UpdateValueRoute")] 53 | public void Put(int id, [FromBody] string value) 54 | { 55 | } 56 | 57 | // DELETE api/values/5 58 | [HttpDelete("{id}", Name = "DeleteValueRoute")] 59 | public void Delete(int id) 60 | { 61 | } 62 | 63 | private async Task> GetAllValuesWithLinksAsync() 64 | { 65 | var values = await repo.GetAllValuesAsync(); 66 | values.ForEach(async x => await linksService.AddLinksAsync(x)); 67 | return values; ; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/Models/ValueInfo.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | 3 | namespace RiskFirst.Hateoas.BasicSample.Models 4 | { 5 | public class ValueInfo : LinkContainer 6 | { 7 | public int Id { get; set; } 8 | public string Value { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/Program.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas; 2 | using RiskFirst.Hateoas.BasicSample.Models; 3 | using RiskFirst.Hateoas.BasicSample.Repository; 4 | using RiskFirst.Hateoas.Models; 5 | 6 | public partial class Program 7 | { 8 | private static void Main(string[] args) 9 | { 10 | var builder = WebApplication.CreateBuilder(args); 11 | 12 | var services = builder.Services; 13 | 14 | services.AddControllers() 15 | .AddNewtonsoftJson() 16 | .AddXmlSerializerFormatters(); 17 | services.AddScoped(); 18 | services.AddLinks(config => 19 | { 20 | config.AddPolicy(policy => 21 | { 22 | policy.RequireRoutedLink("self", "GetValueByIdRoute", x => new { id = x.Id }); 23 | }); 24 | 25 | config.AddPolicy("FullInfoPolicy", policy => 26 | { 27 | policy.RequireSelfLink() 28 | .RequireRoutedLink("update", "GetValueByIdRoute", x => new { id = x.Id }) 29 | .RequireRoutedLink("delete", "DeleteValueRoute", x => new { id = x.Id }) 30 | .RequireRoutedLink("all", "GetAllValuesRoute"); 31 | }); 32 | 33 | config.AddPolicy>(policy => 34 | { 35 | policy.RequireSelfLink() 36 | .RequireRoutedLink("insert", "InsertValueRoute"); 37 | }); 38 | }); 39 | 40 | var app = builder.Build(); 41 | 42 | app.UseAuthorization(); 43 | 44 | app.MapControllers(); 45 | 46 | app.Run(); 47 | } 48 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:29019", 8 | "sslPort": 44385 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5153", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7261;http://localhost:5153", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/Repository/ValuesRepository.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.BasicSample.Models; 2 | 3 | namespace RiskFirst.Hateoas.BasicSample.Repository 4 | { 5 | public interface IValuesRepository 6 | { 7 | Task> GetAllValuesAsync(); 8 | Task GetValueAsync(int id); 9 | } 10 | 11 | public class ValuesRepository : IValuesRepository 12 | { 13 | private static IDictionary values = new Dictionary() 14 | { 15 | { 1, "Value One" }, 16 | { 2, "Value Two" }, 17 | { 3, "Value Three" }, 18 | }; 19 | 20 | public async Task> GetAllValuesAsync() 21 | { 22 | return values.Select(v => new ValueInfo() 23 | { 24 | Id = v.Key, 25 | Value = v.Value 26 | }).ToList(); 27 | } 28 | 29 | public async Task GetValueAsync(int id) 30 | { 31 | return (values.ContainsKey(id)) 32 | ? new ValueInfo() 33 | { 34 | Id = id, 35 | Value = values[id] 36 | } 37 | : null; 38 | 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/RiskFirst.Hateoas.BasicSample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net7.0 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/src/RiskFirst.Hateoas.BasicSample/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/tests/RiskFirst.Hateoas.BasicSample.Tests/LinksTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | using System.Xml.Serialization; 3 | using Microsoft.AspNetCore.Mvc.Testing; 4 | using Newtonsoft.Json; 5 | using RiskFirst.Hateoas.BasicSample.Models; 6 | using RiskFirst.Hateoas.Models; 7 | 8 | namespace RiskFirst.Hateoas.BasicSample.Tests; 9 | 10 | public class BasicTests 11 | : IClassFixture> 12 | { 13 | private readonly WebApplicationFactory _factory; 14 | 15 | public BasicTests(WebApplicationFactory factory) 16 | { 17 | _factory = factory; 18 | } 19 | 20 | [Fact] 21 | public async Task GetAllValues_Json_ReturnsObjectsWithLinks() 22 | { 23 | // Arrange 24 | var client = _factory.CreateClient(); 25 | 26 | // Act 27 | var response = await client.GetAsync("/api/values"); 28 | response.EnsureSuccessStatusCode(); 29 | 30 | var responseString = await response.Content.ReadAsStringAsync(); 31 | var values = JsonConvert.DeserializeObject>(responseString); 32 | 33 | Assert.All(values.Items, i => Assert.True(i.Links.Count > 0, "Invalid number of links")); 34 | } 35 | 36 | [Fact] 37 | public async Task GetAllValues_Xml_ReturnsObjectsWithLinks() 38 | { 39 | // Arrange 40 | var client = _factory.CreateClient(); 41 | 42 | // Act 43 | client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); 44 | var response = await client.GetAsync("/api/values"); 45 | response.EnsureSuccessStatusCode(); 46 | 47 | var responseString = await response.Content.ReadAsStringAsync(); 48 | var values = DeserializeXml>(responseString); 49 | 50 | Assert.All(values.Items, i => Assert.True(i.Links.Count > 0, "Invalid number of links")); 51 | } 52 | 53 | [Fact] 54 | public async Task GetValue_Json_AlternateRoute_ReturnsObjectsWithLinks() 55 | { 56 | // Arrange 57 | var client = _factory.CreateClient(); 58 | 59 | // Act 60 | var response = await client.GetAsync("/api/values/v2/1"); 61 | response.EnsureSuccessStatusCode(); 62 | 63 | var responseString = await response.Content.ReadAsStringAsync(); 64 | var value = JsonConvert.DeserializeObject(responseString); 65 | 66 | Assert.True(value.Links.Count > 0, "Invalid number of links"); 67 | } 68 | 69 | [Fact] 70 | public async Task GetValue_Xml_AlternateRoute_ReturnsObjectsWithLinks() 71 | { 72 | // Arrange 73 | var client = _factory.CreateClient(); 74 | 75 | // Act 76 | client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); 77 | var response = await client.GetAsync("/api/values/v2/1"); 78 | response.EnsureSuccessStatusCode(); 79 | 80 | var responseString = await response.Content.ReadAsStringAsync(); 81 | var value = DeserializeXml(responseString); 82 | 83 | Assert.True(value.Links.Count > 0, "Invalid number of links"); 84 | } 85 | 86 | [Fact] 87 | public async Task GetValue_Json_ReturnsObjectsWithLinks() 88 | { 89 | // Arrange 90 | var client = _factory.CreateClient(); 91 | 92 | // Act 93 | var response = await client.GetAsync("/api/values/1"); 94 | response.EnsureSuccessStatusCode(); 95 | 96 | var responseString = await response.Content.ReadAsStringAsync(); 97 | var value = JsonConvert.DeserializeObject(responseString); 98 | 99 | Assert.True(value.Links.Count > 0, "Invalid number of links"); 100 | } 101 | 102 | [Fact] 103 | public async Task GetValue_Xml_ReturnsObjectsWithLinks() 104 | { 105 | // Arrange 106 | var client = _factory.CreateClient(); 107 | 108 | // Act 109 | client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); 110 | var response = await client.GetAsync("/api/values/1"); 111 | response.EnsureSuccessStatusCode(); 112 | 113 | var responseString = await response.Content.ReadAsStringAsync(); 114 | var value = DeserializeXml(responseString); 115 | 116 | Assert.True(value.Links.Count > 0, "Invalid number of links"); 117 | } 118 | 119 | private static T DeserializeXml(string xml) 120 | { 121 | using (var reader = new StringReader(xml)) 122 | { 123 | var serializer = new XmlSerializer(typeof(T)); 124 | return (T)serializer.Deserialize(reader); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/tests/RiskFirst.Hateoas.BasicSample.Tests/RiskFirst.Hateoas.BasicSample.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.BasicSample/tests/RiskFirst.Hateoas.BasicSample.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Controllers/RootController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using RiskFirst.Hateoas.CustomRequirementSample.Models; 3 | 4 | namespace RiskFirst.Hateoas.CustomRequirementSample.Controllers 5 | { 6 | [ApiController] 7 | [Route("api")] 8 | public class RootController : ControllerBase 9 | { 10 | private readonly ILinksService linksService; 11 | 12 | public RootController(ILinksService linksService) 13 | { 14 | this.linksService = linksService; 15 | } 16 | 17 | 18 | [HttpGet("", Name = "ApiRoot")] 19 | public async Task GetApiInfo() 20 | { 21 | var info = new ApiInfo() 22 | { 23 | Version = "v1.0" 24 | }; 25 | await linksService.AddLinksAsync(info); 26 | 27 | return info; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Controllers/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using RiskFirst.Hateoas.CustomRequirementSample.Models; 3 | using RiskFirst.Hateoas.CustomRequirementSample.Repository; 4 | using RiskFirst.Hateoas.Models; 5 | 6 | namespace RiskFirst.Hateoas.CustomRequirementSample.Controllers; 7 | 8 | [ApiController] 9 | [Route("api/[controller]")] 10 | public class ValuesController : ControllerBase 11 | { 12 | private readonly IValuesRepository repo; 13 | private readonly ILinksService linksService; 14 | 15 | public ValuesController(IValuesRepository repo, ILinksService linksService) 16 | { 17 | this.repo = repo; 18 | this.linksService = linksService; 19 | } 20 | 21 | // GET api/values 22 | [HttpGet(Name = "GetAllValuesRoute")] 23 | public async Task> Get() 24 | { 25 | var values = await GetAllValuesWithLinksAsync(); 26 | 27 | var result = new ItemsLinkContainer() 28 | { 29 | Items = values 30 | }; 31 | await linksService.AddLinksAsync(result); 32 | return result; 33 | } 34 | 35 | // GET api/values/5 36 | [HttpGet("{id}", Name = "GetValueByIdRoute")] 37 | [Links(Policy = "FullInfoPolicy")] 38 | public async Task Get(int id) 39 | { 40 | var value = await repo.GetValueAsync(id); 41 | await linksService.AddLinksAsync(value); 42 | return value; 43 | } 44 | 45 | // POST api/values 46 | [HttpPost(Name = "InsertValueRoute")] 47 | public void Post([FromBody] string value) 48 | { 49 | } 50 | 51 | // PUT api/values/5 52 | [HttpPut("{id}", Name = "UpdateValueRoute")] 53 | public void Put(int id, [FromBody] string value) 54 | { 55 | } 56 | 57 | // DELETE api/values/5 58 | [HttpDelete("{id}", Name = "DeleteValueRoute")] 59 | public void Delete(int id) 60 | { 61 | } 62 | 63 | private async Task> GetAllValuesWithLinksAsync() 64 | { 65 | var values = await repo.GetAllValuesAsync(); 66 | values.ForEach(async x => await linksService.AddLinksAsync(x)); 67 | return values; ; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/LinksPolicyBuiderExtensions.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.CustomRequirementSample.Requirement; 2 | 3 | namespace RiskFirst.Hateoas.CustomRequirementSample 4 | { 5 | public static class LinksPolicyBuiderExtensions 6 | { 7 | public static LinksPolicyBuilder RequiresApiRootLink(this LinksPolicyBuilder builder) 8 | where TResource : class 9 | { 10 | return builder.Requires(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Models/ApiInfo.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | 3 | namespace RiskFirst.Hateoas.CustomRequirementSample.Models 4 | { 5 | public class ApiInfo : LinkContainer 6 | { 7 | public string Version { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Models/ValueInfo.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | 3 | namespace RiskFirst.Hateoas.CustomRequirementSample.Models 4 | { 5 | public class ValueInfo : LinkContainer 6 | { 7 | public int Id { get; set; } 8 | public string Value { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Program.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas; 2 | using RiskFirst.Hateoas.CustomRequirementSample; 3 | using RiskFirst.Hateoas.CustomRequirementSample.Models; 4 | using RiskFirst.Hateoas.CustomRequirementSample.Repository; 5 | using RiskFirst.Hateoas.CustomRequirementSample.Requirement; 6 | using RiskFirst.Hateoas.Models; 7 | 8 | public partial class Program 9 | { 10 | private static void Main(string[] args) 11 | { 12 | var builder = WebApplication.CreateBuilder(args); 13 | 14 | var services = builder.Services; 15 | 16 | services.AddControllers() 17 | .AddNewtonsoftJson(); 18 | 19 | services.AddTransient(); 20 | services.AddScoped(); 21 | 22 | services.AddLinks(config => 23 | { 24 | config.AddPolicy(policy => 25 | { 26 | policy.RequireRoutedLink("self", "GetValueByIdRoute", x => new { id = x.Id }); 27 | }); 28 | config.AddPolicy("FullInfoPolicy", policy => 29 | { 30 | policy.RequireSelfLink() 31 | .RequireRoutedLink("update", "GetValueByIdRoute", x => new { id = x.Id }) 32 | .RequireRoutedLink("delete", "DeleteValueRoute", x => new { id = x.Id }) 33 | .RequireRoutedLink("all", "GetAllValuesRoute"); 34 | }); 35 | 36 | config.AddPolicy>(policy => 37 | { 38 | policy.RequireSelfLink() 39 | .RequiresApiRootLink() // Link to the API root from a collection of values 40 | .RequireRoutedLink("insert", "InsertValueRoute"); 41 | }); 42 | 43 | config.AddPolicy(policy => 44 | { 45 | policy.RequireSelfLink() 46 | .RequireRoutedLink("values", "GetAllValuesRoute"); 47 | }); 48 | }); 49 | 50 | var app = builder.Build(); 51 | 52 | app.MapControllers(); 53 | 54 | app.Run(); 55 | } 56 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:4285", 8 | "sslPort": 44388 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5186", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7027;http://localhost:5186", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Repository/ValuesRepository.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.CustomRequirementSample.Models; 2 | 3 | namespace RiskFirst.Hateoas.CustomRequirementSample.Repository 4 | { 5 | public interface IValuesRepository 6 | { 7 | Task> GetAllValuesAsync(); 8 | Task GetValueAsync(int id); 9 | } 10 | 11 | public class ValuesRepository : IValuesRepository 12 | { 13 | private static IDictionary values = new Dictionary() 14 | { 15 | {1,"Value One" }, 16 | {2,"Value Two" }, 17 | { 3,"Value Three" }, 18 | }; 19 | public async Task> GetAllValuesAsync() 20 | { 21 | return values.Select(v => new ValueInfo() 22 | { 23 | Id = v.Key, 24 | Value = v.Value 25 | }).ToList(); 26 | } 27 | 28 | public async Task GetValueAsync(int id) 29 | { 30 | return (values.ContainsKey(id)) 31 | ? new ValueInfo() 32 | { 33 | Id = id, 34 | Value = values[id] 35 | } 36 | : null; 37 | 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Requirement/ApiRootLinkHandler.cs: -------------------------------------------------------------------------------- 1 | namespace RiskFirst.Hateoas.CustomRequirementSample.Requirement 2 | { 3 | public class ApiRootLinkHandler : LinksHandler 4 | { 5 | protected override Task HandleRequirementAsync(LinksHandlerContext context, ApiRootLinkRequirement requirement) 6 | { 7 | var route = context.RouteMap.GetRoute("ApiRoot"); // Assumes your controller has a named route "ApiRoot". 8 | context.Links.Add(new LinkSpec(requirement.Id, route)); 9 | context.Handled(requirement); 10 | return Task.CompletedTask; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/Requirement/ApiRootLinkRequirement.cs: -------------------------------------------------------------------------------- 1 | namespace RiskFirst.Hateoas.CustomRequirementSample.Requirement 2 | { 3 | public class ApiRootLinkRequirement : ILinksRequirement 4 | { 5 | public ApiRootLinkRequirement() 6 | { 7 | } 8 | public string Id { get; set; } = "root"; 9 | } 10 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/RiskFirst.Hateoas.CustomRequirementSample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/src/RiskFirst.Hateoas.CustomRequirementSample/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/tests/RiskFirst.Hateoas.CustomRequirementsSample.Tests/LinkEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using RiskFirst.Hateoas.Models; 3 | 4 | namespace RiskFirst.Hateoas.CustomRequirementsSample.Tests 5 | { 6 | public class LinkEqualityComparer : IEqualityComparer 7 | { 8 | public bool Equals(Link? x, Link? y) 9 | { 10 | if (x == null && y == null) 11 | return true; 12 | else if (x == null || y == null) 13 | return false; 14 | else if (x.Href == y.Href 15 | && x.Method == y.Method 16 | && x.Name == y.Name 17 | && x.Rel == y.Rel) 18 | return true; 19 | else 20 | return false; 21 | } 22 | 23 | public int GetHashCode([DisallowNull] Link obj) 24 | { 25 | string hCode = $"{obj.Href}{obj.Method}{obj.Name}{obj.Rel}"; 26 | return hCode.GetHashCode(); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/tests/RiskFirst.Hateoas.CustomRequirementsSample.Tests/LinkTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Newtonsoft.Json; 3 | using RiskFirst.Hateoas.CustomRequirementSample.Models; 4 | using RiskFirst.Hateoas.CustomRequirementsSample.Tests; 5 | using RiskFirst.Hateoas.Models; 6 | 7 | namespace RiskFirst.Hateoas.CustomRequirementsSample.TestsNew; 8 | 9 | public class RootApiTests 10 | : IClassFixture> 11 | { 12 | private readonly WebApplicationFactory _factory; 13 | 14 | public RootApiTests(WebApplicationFactory factory) 15 | { 16 | _factory = factory; 17 | } 18 | 19 | [Fact] 20 | public async Task GetAllValues_Json_ReturnsLinksWithRootApi() 21 | { 22 | // Arrange 23 | var client = _factory.CreateClient(); 24 | 25 | // Act 26 | var response = await client.GetAsync("/api/values"); 27 | response.EnsureSuccessStatusCode(); 28 | 29 | var responseString = await response.Content.ReadAsStringAsync(); 30 | var values = JsonConvert.DeserializeObject>(responseString); 31 | 32 | Assert.Contains( 33 | new Link 34 | { 35 | Href = "http://localhost/api", 36 | Method = "GET", 37 | Name = "root", 38 | Rel = "Root/ApiRoot" 39 | }, 40 | values.Links, 41 | new LinkEqualityComparer()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/tests/RiskFirst.Hateoas.CustomRequirementsSample.Tests/RiskFirst.Hateoas.CustomRequirementsSample.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.CustomRequirementSample/tests/RiskFirst.Hateoas.CustomRequirementsSample.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Controllers/ModelsController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace RiskFirst.Hateoas.LinkConfigurationSample.Controllers; 4 | 5 | [ApiController] 6 | [Route("models")] 7 | public class ModelsController : ControllerBase 8 | { 9 | [HttpGet("{model}")] 10 | public object GetEntity(string model) 11 | { 12 | var type = model.Replace("-", "."); 13 | return type; 14 | } 15 | [HttpGet("{container}/of/{model}")] 16 | public object GetContainerEntity(string container, string model) 17 | { 18 | var containerType = container.Replace("-", "."); 19 | var modelType = model.Replace("-", "."); 20 | return $"{containerType}<{modelType}>"; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Controllers/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using RiskFirst.Hateoas.LinkConfigurationSample.Models; 3 | using RiskFirst.Hateoas.LinkConfigurationSample.Repository; 4 | using RiskFirst.Hateoas.Models; 5 | 6 | namespace RiskFirst.Hateoas.LinkConfigurationSample.Controllers 7 | { 8 | [ApiController] 9 | [Route("api/[controller]")] 10 | public class ValuesController : ControllerBase 11 | { 12 | private readonly IValuesRepository repo; 13 | private readonly ILinksService linksService; 14 | 15 | public ValuesController(IValuesRepository repo, ILinksService linksService) 16 | { 17 | this.repo = repo; 18 | this.linksService = linksService; 19 | } 20 | 21 | // GET api/values 22 | [HttpGet(Name = "GetAllValuesRoute")] 23 | public async Task> Get() 24 | { 25 | var values = await GetAllValuesWithLinksAsync(); 26 | 27 | var result = new ItemsLinkContainer() 28 | { 29 | Items = values 30 | }; 31 | await linksService.AddLinksAsync(result); 32 | return result; 33 | } 34 | 35 | // GET api/values/5 36 | [HttpGet("{id}", Name = "GetValueByIdRoute")] 37 | [Links(Policy = "FullInfoPolicy")] 38 | public async Task Get(int id) 39 | { 40 | var value = await repo.GetValueAsync(id); 41 | await linksService.AddLinksAsync(value); 42 | return value; 43 | } 44 | 45 | // POST api/values 46 | [HttpPost(Name = "InsertValueRoute")] 47 | public void Post([FromBody] string value) 48 | { 49 | } 50 | 51 | // PUT api/values/5 52 | [HttpPut("{id}", Name = "UpdateValueRoute")] 53 | public void Put(int id, [FromBody] string value) 54 | { 55 | } 56 | 57 | // DELETE api/values/5 58 | [HttpDelete("{id}", Name = "DeleteValueRoute")] 59 | public void Delete(int id) 60 | { 61 | } 62 | 63 | private async Task> GetAllValuesWithLinksAsync() 64 | { 65 | var values = await repo.GetAllValuesAsync(); 66 | values.ForEach(async x => await linksService.AddLinksAsync(x)); 67 | return values; ; 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Extensions/LinkTransformationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace RiskFirst.Hateoas.LinkConfigurationSample.Extensions 4 | { 5 | public static class LinkTransformationBuilderExtensions 6 | { 7 | public static LinkTransformationBuilder AddModelPath(this LinkTransformationBuilder builder) 8 | { 9 | return builder.Add("/models/").Add(ctx => 10 | { 11 | var returnType = ctx?.LinkSpec.RouteInfo?.MethodInfo?.ReturnType; 12 | var returnTypeInfo = returnType.GetTypeInfo(); 13 | if (returnTypeInfo.IsGenericType) 14 | { 15 | if (returnType.GetGenericTypeDefinition().IsAssignableFrom(typeof(Task<>))) 16 | { 17 | return GetTypePathInfo(returnType.GetGenericArguments()[0]); 18 | } 19 | } 20 | return GetTypePathInfo(returnType); 21 | 22 | }); 23 | } 24 | 25 | private static string GetTypePathInfo(Type type) 26 | { 27 | if (type.IsConstructedGenericType) 28 | { 29 | var genericParam = type.GetGenericArguments()[0]; 30 | return $"{type.Namespace.Urlify()}-{type.Name}/of/{GetTypePathInfo(genericParam)}"; 31 | } 32 | else 33 | { 34 | return $"{type.Namespace.Urlify()}-{type.Name}"; 35 | } 36 | } 37 | 38 | private static string Urlify(this string str) 39 | { 40 | return str.Replace(".", "-"); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Extensions/ModelRelTransformation.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text; 3 | 4 | namespace RiskFirst.Hateoas.LinkConfigurationSample.Extensions 5 | { 6 | public class ModelRelTransformation : ILinkTransformation 7 | { 8 | public string Transform(LinkTransformationContext context) 9 | { 10 | var sb = new StringBuilder(); 11 | sb.Append(context.HttpContext.Request.Scheme) 12 | .Append("://") 13 | .Append(context.HttpContext.Request.Host.ToUriComponent()) 14 | .Append("/models/"); 15 | 16 | var returnType = context?.LinkSpec.RouteInfo?.MethodInfo?.ReturnType; 17 | var returnTypeInfo = returnType.GetTypeInfo(); 18 | if (returnTypeInfo.IsGenericType) 19 | { 20 | if (returnType.GetGenericTypeDefinition().IsAssignableFrom(typeof(Task<>))) 21 | { 22 | sb.Append(GetTypePathInfo(returnType.GetGenericArguments()[0])); 23 | } 24 | else 25 | { 26 | sb.Append(GetTypePathInfo(returnType)); 27 | } 28 | } 29 | else 30 | { 31 | sb.Append(GetTypePathInfo(returnType)); 32 | } 33 | return sb.ToString(); 34 | } 35 | 36 | private static string GetTypePathInfo(Type type) 37 | { 38 | if (type.IsConstructedGenericType) 39 | { 40 | var genericParam = type.GetGenericArguments()[0]; 41 | return $"{Urlify(type.Namespace)}-{type.Name}/of/{GetTypePathInfo(genericParam)}"; 42 | } 43 | else 44 | { 45 | return $"{Urlify(type.Namespace)}-{type.Name}"; 46 | } 47 | } 48 | 49 | private static string Urlify(string str) 50 | { 51 | return str.Replace(".", "-"); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Models/ValueInfo.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | 3 | namespace RiskFirst.Hateoas.LinkConfigurationSample.Models 4 | { 5 | public class ValueInfo : LinkContainer 6 | { 7 | public int Id { get; set; } 8 | public string Value { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Program.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas; 2 | using RiskFirst.Hateoas.LinkConfigurationSample.Extensions; 3 | using RiskFirst.Hateoas.LinkConfigurationSample.Models; 4 | using RiskFirst.Hateoas.LinkConfigurationSample.Repository; 5 | using RiskFirst.Hateoas.Models; 6 | 7 | public partial class Program 8 | { 9 | private static void Main(string[] args) 10 | { 11 | var builder = WebApplication.CreateBuilder(args); 12 | 13 | var services = builder.Services; 14 | 15 | services.AddControllers() 16 | .AddNewtonsoftJson(); 17 | services.AddScoped(); 18 | services.AddLinks(config => 19 | { 20 | // Uncomment out one of the two lines below to see how to configure the rel value of links to contain a uri to the returned model 21 | // 22 | // config.ConfigureRelTransformation(transform => transform.AddProtocol().AddHost().AddModelPath()); 23 | config.UseRelTransformation(); 24 | 25 | config.AddPolicy(policy => 26 | { 27 | policy.RequireRoutedLink("self", "GetValueByIdRoute", x => new { id = x.Id }); 28 | }); 29 | config.AddPolicy("FullInfoPolicy", policy => 30 | { 31 | policy.RequireSelfLink() 32 | .RequireRoutedLink("update", "GetValueByIdRoute", x => new { id = x.Id }) 33 | .RequireRoutedLink("delete", "DeleteValueRoute", x => new { id = x.Id }) 34 | .RequireRoutedLink("all", "GetAllValuesRoute"); 35 | }); 36 | 37 | config.AddPolicy>(policy => 38 | { 39 | policy.RequireSelfLink() 40 | .RequireRoutedLink("insert", "InsertValueRoute"); 41 | }); 42 | }); 43 | 44 | var app = builder.Build(); 45 | 46 | app.UseAuthorization(); 47 | 48 | app.MapControllers(); 49 | 50 | app.Run(); 51 | } 52 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:57514", 8 | "sslPort": 44340 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5057", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7271;http://localhost:5057", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/Repository/ValuesRepository.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.LinkConfigurationSample.Models; 2 | 3 | namespace RiskFirst.Hateoas.LinkConfigurationSample.Repository 4 | { 5 | public interface IValuesRepository 6 | { 7 | Task> GetAllValuesAsync(); 8 | Task GetValueAsync(int id); 9 | } 10 | 11 | public class ValuesRepository : IValuesRepository 12 | { 13 | private static IDictionary values = new Dictionary() 14 | { 15 | {1,"Value One" }, 16 | {2,"Value Two" }, 17 | { 3,"Value Three" }, 18 | }; 19 | public async Task> GetAllValuesAsync() 20 | { 21 | return values.Select(v => new ValueInfo() 22 | { 23 | Id = v.Key, 24 | Value = v.Value 25 | }).ToList(); 26 | } 27 | 28 | public async Task GetValueAsync(int id) 29 | { 30 | return (values.ContainsKey(id)) 31 | ? new ValueInfo() 32 | { 33 | Id = id, 34 | Value = values[id] 35 | } 36 | : null; 37 | 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/RiskFirst.Hateoas.LinkConfigurationSample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Samples/RiskFirst.Hateoas.LinkConfigurationSample/src/RiskFirst.Hateoas.LinkConfigurationSample/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/ILinkContainer.cs: -------------------------------------------------------------------------------- 1 | namespace RiskFirst.Hateoas.Models 2 | { 3 | public interface ILinkContainer 4 | { 5 | LinkCollection Links { get; set; } 6 | void Add(Link link); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/IPagedLinkContainer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Models 7 | { 8 | public interface IPagedLinkContainer : ILinkContainer 9 | { 10 | int PageSize { get; set; } 11 | int PageNumber { get; set; } 12 | int PageCount { get; set; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/ItemsLinkContainer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Models 7 | { 8 | public class ItemsLinkContainer : LinkContainer 9 | { 10 | private List _items; 11 | public List Items 12 | { 13 | get { return _items ?? (_items = new List()); } 14 | set { _items = value; } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/Link.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System.Xml.Serialization; 3 | 4 | namespace RiskFirst.Hateoas.Models 5 | { 6 | public class Link 7 | { 8 | [XmlAttribute("href")] 9 | public string Href { get; set; } 10 | 11 | [XmlAttribute("method")] 12 | public string Method { get; set; } 13 | 14 | [XmlAttribute("rel")] 15 | [JsonIgnore] 16 | public string Name { get; set; } 17 | 18 | [XmlIgnore] 19 | public string Rel { get; set; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/LinkCollection.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | 5 | namespace RiskFirst.Hateoas.Models 6 | { 7 | [JsonConverter(typeof(LinkCollectionConverter))] 8 | public class LinkCollection : IEnumerable 9 | { 10 | private readonly Dictionary links = new Dictionary(); 11 | 12 | public int Count => links.Count; 13 | 14 | public void Add(Link link) => 15 | links.Add(link.Name, link); 16 | 17 | public bool ContainsKey(string key) => 18 | links.ContainsKey(key); 19 | 20 | public IEnumerator GetEnumerator() => 21 | links.Values.GetEnumerator(); 22 | 23 | IEnumerator IEnumerable.GetEnumerator() => 24 | GetEnumerator(); 25 | 26 | public Link this[string name] 27 | { 28 | get => links[name]; 29 | set => links[name] = value; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/LinkCollectionConverter.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | namespace RiskFirst.Hateoas.Models 7 | { 8 | public class LinkCollectionConverter : JsonConverter 9 | { 10 | public override LinkCollection ReadJson(JsonReader reader, Type objectType, LinkCollection existingValue, bool hasExistingValue, JsonSerializer serializer) 11 | { 12 | var links = (Dictionary)serializer 13 | .Deserialize(reader, typeof(Dictionary)); 14 | 15 | foreach (var link in links) 16 | { 17 | link.Value.Name = link.Key; 18 | existingValue.Add(link.Value); 19 | } 20 | 21 | return existingValue; 22 | } 23 | 24 | public override void WriteJson(JsonWriter writer, LinkCollection value, JsonSerializer serializer) 25 | { 26 | var links = value? 27 | .ToDictionary(x => x.Name, x => x); 28 | 29 | serializer.Serialize(writer, links); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/LinkContainer.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Serialization; 3 | using System.Xml.Serialization; 4 | 5 | namespace RiskFirst.Hateoas.Models 6 | { 7 | public abstract class LinkContainer : ILinkContainer 8 | { 9 | [XmlElement("link")] 10 | [JsonProperty(PropertyName = "_links")] 11 | public LinkCollection Links { get; set; } = new LinkCollection(); 12 | 13 | public void Add(Link link) 14 | { 15 | Links.Add(link); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/PagedItemsLinkContainer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Models 7 | { 8 | public class PagedItemsLinkContainer : ItemsLinkContainer, IPagedLinkContainer 9 | { 10 | public int PageSize { get; set; } 11 | public int PageCount { get; set; } 12 | 13 | public int PageNumber { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas.Models/RiskFirst.Hateoas.Models.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0 5 | RiskFirst.Hateoas.Models 6 | RiskFirst.Hateoas.Models 7 | false 8 | false 9 | false 10 | False 11 | https://github.com/riskfirst/riskfirst.hateoas 12 | RiskFirst 13 | https://github.com/riskfirst/riskfirst.hateoas/blob/master/LICENSE 14 | Model classes for RiskFirst.Hateoas 15 | https://github.com/riskfirst/riskfirst.hateoas 16 | GIT 17 | HATEOAS aspnet dotnetcore 18 | 19 | v3.0.0 - extract models to separate assembly 20 | v3.1.0 - version bump to match main assembly 21 | v3.1.1 - version bump to match main assembly 22 | v3.1.2 - Patch Security Issues 23 | v4.0.0 - version bump to match main assembly 24 | 25 | 4.0.0 26 | https://raw.githubusercontent.com/riskfirst/pkgicons/master/riskfirst-pkg.png 27 | 4.0.0.0 28 | 4.0.0.0 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/BuilderLinkTransformation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace RiskFirst.Hateoas 8 | { 9 | public class BuilderLinkTransformation : ILinkTransformation 10 | { 11 | private readonly IList> transforms; 12 | public BuilderLinkTransformation(IList> transforms) 13 | { 14 | this.transforms = transforms; 15 | } 16 | 17 | public string Transform(LinkTransformationContext context) 18 | { 19 | var builder = transforms.Aggregate(new StringBuilder(), (sb, transform) => 20 | { 21 | sb.Append(transform(context)); 22 | return sb; 23 | }); 24 | return builder.ToString(); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/DefaultLinkAuthorizationService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Routing; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Security.Claims; 8 | using System.Threading.Tasks; 9 | 10 | namespace RiskFirst.Hateoas 11 | { 12 | public class DefaultLinkAuthorizationService : ILinkAuthorizationService 13 | { 14 | private readonly IAuthorizationService authService; 15 | public DefaultLinkAuthorizationService(IAuthorizationService authService) 16 | { 17 | if (authService == null) 18 | throw new ArgumentNullException(nameof(authService)); 19 | this.authService = authService; 20 | } 21 | 22 | public async Task AuthorizeLink(LinkAuthorizationContext context) 23 | { 24 | if (!(context.User?.Identity?.IsAuthenticated ?? false)) 25 | return false; 26 | 27 | if (context.ShouldAuthorizeRoute) 28 | { 29 | var authAttrs = context.RouteInfo.AuthorizeAttributes; 30 | foreach (var authAttr in authAttrs) 31 | { 32 | if (!await AuthorizeData(authAttr, context.User, context.RouteValues)) 33 | { 34 | return false; 35 | } 36 | } 37 | } 38 | if (context.AuthorizationRequirements.Any()) 39 | { 40 | if (!(await authService.AuthorizeAsync(context.User, context.Resource, context.AuthorizationRequirements)).Succeeded) 41 | { 42 | return false; 43 | } 44 | } 45 | if(context.AuthorizationPolicyNames.Any()) 46 | { 47 | var tasks = context.AuthorizationPolicyNames.Select(policyName => authService.AuthorizeAsync(context.User, context.Resource, policyName)).ToList(); 48 | await Task.WhenAll(tasks); 49 | 50 | if(!tasks.All(x => x.Result.Succeeded)) 51 | { 52 | return false; 53 | } 54 | } 55 | return true; 56 | } 57 | 58 | protected virtual async Task AuthorizeData(IAuthorizeData authData, ClaimsPrincipal user, RouteValueDictionary values) 59 | { 60 | if (!user.Identity.IsAuthenticated) 61 | return false; 62 | 63 | if (!String.IsNullOrEmpty(authData.Roles)) 64 | { 65 | if (!authData.Roles.Split(',').Any(r => user.IsInRole(r))) 66 | { 67 | return false; 68 | } 69 | } 70 | if (!String.IsNullOrEmpty(authData.Policy) && !(await authService.AuthorizeAsync(user, values, authData.Policy)).Succeeded) 71 | { 72 | return false; 73 | } 74 | return true; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/DefaultLinkTransformationContextFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Infrastructure; 2 | using Microsoft.Extensions.Logging; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Routing; 8 | 9 | namespace RiskFirst.Hateoas 10 | { 11 | public class DefaultLinkTransformationContextFactory : ILinkTransformationContextFactory 12 | { 13 | private readonly IActionContextAccessor actionAccessor; 14 | private readonly ILoggerFactory loggerFactory; 15 | private readonly LinkGenerator generator; 16 | 17 | public DefaultLinkTransformationContextFactory(IActionContextAccessor actionAccessor, ILoggerFactory loggerFactory, LinkGenerator generator) 18 | { 19 | this.actionAccessor = actionAccessor; 20 | this.loggerFactory = loggerFactory; 21 | this.generator = generator; 22 | } 23 | public LinkTransformationContext CreateContext(ILinkSpec spec) 24 | { 25 | return new LinkTransformationContext(spec, actionAccessor.ActionContext, generator); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/DefaultLinksEvaluator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using RiskFirst.Hateoas.Models; 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public class DefaultLinksEvaluator : ILinksEvaluator 9 | { 10 | private readonly LinksOptions options; 11 | private readonly ILinkTransformationContextFactory contextFactory; 12 | public DefaultLinksEvaluator(IOptions options, ILinkTransformationContextFactory contextFactory) 13 | { 14 | this.options = options.Value; 15 | this.contextFactory = contextFactory; 16 | } 17 | 18 | public void BuildLinks(IEnumerable links, ILinkContainer container) 19 | { 20 | foreach (var link in links) 21 | { 22 | var context = contextFactory.CreateContext(link); 23 | try 24 | { 25 | container.Add(new Link() 26 | { 27 | Name = link.Id, 28 | Href = options.HrefTransformation?.Transform(context), 29 | Rel = options.RelTransformation?.Transform(context), 30 | Method = link.HttpMethod.ToString() 31 | }); 32 | } 33 | catch(Exception ex) 34 | { 35 | throw new LinkTransformationException($"Unable to transform link {link.Id}", ex, context); 36 | } 37 | } 38 | } 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/DefaultLinksHandlerContextFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.Infrastructure; 4 | using Microsoft.Extensions.Logging; 5 | using System.Collections.Generic; 6 | 7 | namespace RiskFirst.Hateoas 8 | { 9 | public class DefaultLinksHandlerContextFactory : ILinksHandlerContextFactory 10 | { 11 | private readonly IRouteMap routeMap; 12 | private readonly ILinkAuthorizationService authService; 13 | private readonly ActionContext actionContext; 14 | private readonly ILoggerFactory loggerFactory; 15 | 16 | public DefaultLinksHandlerContextFactory(IRouteMap routeMap, ILinkAuthorizationService authService, IActionContextAccessor actionAccessor, ILoggerFactory loggerFactory) 17 | { 18 | this.routeMap = routeMap; 19 | this.authService = authService; 20 | this.actionContext = actionAccessor.ActionContext; 21 | this.loggerFactory = loggerFactory; 22 | } 23 | public LinksHandlerContext CreateContext(IEnumerable requirements, object resource) 24 | { 25 | var logger = loggerFactory.CreateLogger(); 26 | return new LinksHandlerContext(requirements, routeMap, authService, logger, actionContext, resource); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/DefaultLinksPolicyProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using RiskFirst.Hateoas.Models; 3 | using System; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public class DefaultLinksPolicyProvider : ILinksPolicyProvider 9 | { 10 | private readonly LinksOptions options; 11 | 12 | public DefaultLinksPolicyProvider(IOptions options) 13 | { 14 | if (options == null) 15 | throw new ArgumentNullException(nameof(options)); 16 | 17 | this.options = options.Value; 18 | } 19 | public Task GetDefaultPolicyAsync() 20 | { 21 | return Task.FromResult(options.DefaultPolicy); 22 | } 23 | 24 | public Task GetPolicyAsync() 25 | { 26 | return Task.FromResult(options.GetPolicy()); 27 | } 28 | 29 | public Task GetPolicyAsync(string policyName) 30 | { 31 | return Task.FromResult(options.GetPolicy(policyName)); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/DefaultLinksService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Microsoft.Extensions.Options; 3 | using RiskFirst.Hateoas.Models; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Threading.Tasks; 9 | 10 | namespace RiskFirst.Hateoas 11 | { 12 | public class DefaultLinksService : ILinksService 13 | { 14 | private readonly LinksOptions options; 15 | private readonly ILogger logger; 16 | private readonly ILinksHandlerContextFactory contextFactory; 17 | private readonly ILinksPolicyProvider policyProvider; 18 | private readonly IList handlers; 19 | private readonly IRouteMap routeMap; 20 | private readonly ILinksEvaluator evaluator; 21 | 22 | public DefaultLinksService( 23 | IOptions options, 24 | ILogger logger, 25 | ILinksHandlerContextFactory contextFactory, 26 | ILinksPolicyProvider policyProvider, 27 | IEnumerable handlers, 28 | IRouteMap routeMap, 29 | ILinksEvaluator evaluator) 30 | { 31 | if (options == null) 32 | throw new ArgumentNullException(nameof(options)); 33 | if (logger == null) 34 | throw new ArgumentNullException(nameof(logger)); 35 | if (contextFactory == null) 36 | throw new ArgumentNullException(nameof(contextFactory)); 37 | if (policyProvider == null) 38 | throw new ArgumentNullException(nameof(policyProvider)); 39 | if (handlers == null) 40 | throw new ArgumentNullException(nameof(handlers)); 41 | if (routeMap == null) 42 | throw new ArgumentNullException(nameof(routeMap)); 43 | if (evaluator == null) 44 | throw new ArgumentNullException(nameof(evaluator)); 45 | 46 | this.options = options.Value; 47 | this.logger = logger; 48 | this.contextFactory = contextFactory; 49 | this.policyProvider = policyProvider; 50 | this.handlers = handlers.ToArray(); 51 | this.routeMap = routeMap; 52 | this.evaluator = evaluator; 53 | } 54 | public async Task AddLinksAsync(TResource linkContainer) where TResource : ILinkContainer 55 | { 56 | // look for policies defined on the controller, this is used to override policies defined on the type or registered 57 | var currentRoute = routeMap.GetCurrentRoute(); 58 | if (currentRoute.LinksAttributes != null && currentRoute.LinksAttributes.Any()) 59 | { 60 | foreach (var policyName in currentRoute.LinksAttributes.Select(a => a.Policy)) 61 | { 62 | var controllerPolicy = string.IsNullOrEmpty(policyName) 63 | ? await policyProvider.GetPolicyAsync() 64 | : await policyProvider.GetPolicyAsync(policyName); 65 | if (controllerPolicy == null) 66 | throw new InvalidOperationException($"Controller-registered Policy not defined: {policyName ?? ""} Route: {currentRoute.RouteName}"); 67 | await this.AddLinksAsync(linkContainer, controllerPolicy); 68 | } 69 | return; 70 | } 71 | 72 | // look for policies defined on the type 73 | var typeAttributes = typeof(TResource).GetTypeInfo().GetCustomAttributes(true); 74 | if (typeAttributes.Any()) 75 | { 76 | foreach (var policyName in typeAttributes.Select(a => a.Policy)) 77 | { 78 | var typePolicy = string.IsNullOrEmpty(policyName) 79 | ? await policyProvider.GetPolicyAsync() 80 | : await policyProvider.GetPolicyAsync(policyName); 81 | if (typePolicy == null) 82 | throw new InvalidOperationException($"Type-registered policy not defined: {policyName ?? ""} Type: {typeof(TResource).FullName}"); 83 | await this.AddLinksAsync(linkContainer, typePolicy); 84 | } 85 | return; 86 | } 87 | 88 | // look for a policy registered againt the type 89 | var defaultTypePolicy = await policyProvider.GetPolicyAsync(); 90 | if (defaultTypePolicy != null) 91 | { 92 | await this.AddLinksAsync(linkContainer, defaultTypePolicy); 93 | return; 94 | } 95 | 96 | // fallback to the default policy 97 | var policy = await policyProvider.GetDefaultPolicyAsync(); 98 | await this.AddLinksAsync(linkContainer, policy); 99 | 100 | } 101 | public async Task AddLinksAsync(TResource linkContainer, string policyName) where TResource : ILinkContainer 102 | { 103 | if (policyName == null) 104 | throw new ArgumentNullException(nameof(policyName)); 105 | 106 | 107 | var policy = await policyProvider.GetPolicyAsync(policyName); 108 | if (policy == null) 109 | throw new InvalidOperationException($"Policy not defined: {policyName}"); 110 | 111 | await this.AddLinksAsync(linkContainer, policy); 112 | } 113 | 114 | public async Task AddLinksAsync(TResource linkContainer, IEnumerable requirements) where TResource : ILinkContainer 115 | { 116 | if (requirements == null) 117 | throw new ArgumentNullException(nameof(requirements)); 118 | 119 | logger.LogInformation("Applying links to {ResourceType} using requirements {Requirements}", typeof(TResource).FullName, requirements); 120 | 121 | var sw = new System.Diagnostics.Stopwatch(); 122 | sw.Start(); 123 | var ctx = contextFactory.CreateContext(requirements, linkContainer); 124 | foreach (var handler in handlers) 125 | { 126 | try 127 | { 128 | await handler.HandleAsync(ctx); 129 | } 130 | catch(InvalidCastException) 131 | { 132 | throw; 133 | } 134 | catch(Exception ex) 135 | { 136 | logger.LogWarning("Unhandled exception in {Handler}. Exception: {Exception}. Context: {Context}", handler, ex, ctx); 137 | } 138 | } 139 | if(!ctx.IsSuccess()) 140 | { 141 | logger.LogWarning("Not all links were handled correctly for resource {ResourceType}. Unhandled requirements: {PendingRequirements}", typeof(TResource).FullName, ctx.PendingRequirements.ToArray()); 142 | } 143 | evaluator.BuildLinks(ctx.Links, linkContainer); 144 | sw.Stop(); 145 | 146 | logger.LogInformation("Applied links to {ResourceType} in {ElapsedMilliseconds}ms", typeof(TResource).FullName, sw.ElapsedMilliseconds); 147 | } 148 | 149 | 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/DefaultRouteMap.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.Controllers; 3 | using Microsoft.AspNetCore.Mvc.Infrastructure; 4 | using Microsoft.AspNetCore.Mvc.Routing; 5 | using Microsoft.AspNetCore.Routing; 6 | using Microsoft.Extensions.DependencyModel; 7 | using Microsoft.Extensions.Logging; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Net.Http; 12 | using System.Reflection; 13 | using RiskFirst.Hateoas.Polyfills; 14 | 15 | namespace RiskFirst.Hateoas 16 | { 17 | public class DefaultRouteMap : IRouteMap 18 | { 19 | private readonly IActionContextAccessor contextAccessor; 20 | private readonly ILogger logger; 21 | private IDictionary RouteMap { get; } = new Dictionary(); 22 | public DefaultRouteMap(IActionContextAccessor contextAccessor, ILogger logger, IAssemblyLoader assemblyLoader) 23 | { 24 | if (assemblyLoader == null) 25 | { 26 | throw new ArgumentNullException(nameof(assemblyLoader)); 27 | } 28 | 29 | this.contextAccessor = contextAccessor; 30 | this.logger = logger; 31 | 32 | var assemblies = assemblyLoader.GetAssemblies(); 33 | 34 | foreach (var asm in assemblies) 35 | { 36 | var controllers = asm.GetTypes() 37 | .Where(type => typeof(ControllerBase).IsAssignableFrom(type)); 38 | 39 | var controllerMethods = controllers.SelectMany(c => c.GetMethods(BindingFlags.Public | BindingFlags.Instance) 40 | .Where(m => m.IsDefined(typeof(HttpMethodAttribute))) 41 | .SelectMany(m => m.GetCustomAttributes(), (m, attr) => new 42 | { 43 | Controller = c, 44 | Method = m, 45 | HttpAttribute = attr 46 | })); 47 | 48 | foreach (var attr in controllerMethods.Where(a => !String.IsNullOrWhiteSpace(a.HttpAttribute.Name))) 49 | { 50 | var method = ParseMethod(attr.HttpAttribute.HttpMethods); 51 | RouteMap[attr.HttpAttribute.Name] = new RouteInfo(attr.HttpAttribute.Name, method, new ReflectionControllerMethodInfo(attr.Method)); 52 | } 53 | } 54 | } 55 | 56 | public RouteInfo GetRoute(string name) 57 | { 58 | if (!RouteMap.ContainsKey(name)) 59 | { 60 | return null; 61 | } 62 | return RouteMap[name]; 63 | } 64 | 65 | public RouteInfo GetCurrentRoute() 66 | { 67 | var action = this.contextAccessor?.ActionContext?.ActionDescriptor as ControllerActionDescriptor; 68 | if (action == null) 69 | throw new InvalidOperationException($"Invalid action descriptor in route map"); 70 | var attr = action.EndpointMetadata.OfType().FirstOrDefault(); 71 | if (attr == null) 72 | throw new InvalidOperationException($"Unable to get HttpMethodAttribute in route map"); 73 | var method = ParseMethod(attr.HttpMethods); 74 | return new RouteInfo(attr.Name, method, new ReflectionControllerMethodInfo(action.MethodInfo)); 75 | } 76 | 77 | private HttpMethod ParseMethod(IEnumerable methods) 78 | { 79 | HttpMethod method = HttpMethod.Get; 80 | if (methods != null && methods.Any()) 81 | { 82 | method = new HttpMethod(methods.First()); 83 | } 84 | return method; 85 | } 86 | 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/IControllerMethodInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public interface IControllerMethodInfo 9 | { 10 | Type ControllerType { get; } 11 | Type ReturnType { get; } 12 | string MethodName { get; } 13 | IEnumerable GetAttributes() where TAttribute: Attribute; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinkAuthorizationService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace RiskFirst.Hateoas 4 | { 5 | public interface ILinkAuthorizationService 6 | { 7 | Task AuthorizeLink(LinkAuthorizationContext context); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinkTransformation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public interface ILinkTransformation 9 | { 10 | string Transform(LinkTransformationContext context); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinkTransformationContextFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public interface ILinkTransformationContextFactory 9 | { 10 | LinkTransformationContext CreateContext(ILinkSpec spec); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksEvaluator.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | using System.Collections.Generic; 3 | 4 | namespace RiskFirst.Hateoas 5 | { 6 | public interface ILinksEvaluator 7 | { 8 | void BuildLinks(IEnumerable links, ILinkContainer container); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace RiskFirst.Hateoas 4 | { 5 | public interface ILinksHandler 6 | { 7 | Task HandleAsync(LinksHandlerContext context); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksHandlerContextFactory.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace RiskFirst.Hateoas 4 | { 5 | public interface ILinksHandlerContextFactory 6 | { 7 | LinksHandlerContext CreateContext(IEnumerable requirements, object resource); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksPolicy.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace RiskFirst.Hateoas 4 | { 5 | public interface ILinksPolicy 6 | { 7 | IReadOnlyList Requirements { get; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksPolicyProvider.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | using System.Threading.Tasks; 3 | 4 | namespace RiskFirst.Hateoas 5 | { 6 | public interface ILinksPolicyProvider 7 | { 8 | Task GetDefaultPolicyAsync(); 9 | Task GetPolicyAsync(); 10 | Task GetPolicyAsync(string policyName); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksRequirement.cs: -------------------------------------------------------------------------------- 1 | namespace RiskFirst.Hateoas 2 | { 3 | public interface ILinksRequirement 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksService.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | namespace RiskFirst.Hateoas 6 | { 7 | public interface ILinksService 8 | { 9 | Task AddLinksAsync(TResource linkContainer) where TResource : ILinkContainer; 10 | Task AddLinksAsync(TResource linkContainer, IEnumerable requirements) where TResource : ILinkContainer; 11 | Task AddLinksAsync(TResource linkContainer, string policyName) where TResource : ILinkContainer; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ILinksServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | using System; 3 | using System.Threading.Tasks; 4 | 5 | namespace RiskFirst.Hateoas 6 | { 7 | public static class ILinksServiceExtensions 8 | { 9 | public static Task AddLinksAsync(this ILinksService service, T linkContainer, ILinksPolicy/**/ policy) where T : ILinkContainer 10 | { 11 | if (service == null) 12 | throw new ArgumentNullException(nameof(service)); 13 | if (policy == null) 14 | throw new ArgumentNullException(nameof(policy)); 15 | 16 | return service.AddLinksAsync(linkContainer, policy.Requirements); 17 | } 18 | 19 | public static Task AddLinksAsync(this ILinksService service, T linkContainer, ILinksRequirement requirement) where T : ILinkContainer 20 | { 21 | if (service == null) 22 | throw new ArgumentNullException(nameof(service)); 23 | if (requirement == null) 24 | throw new ArgumentNullException(nameof(requirement)); 25 | 26 | return service.AddLinksAsync(linkContainer, new[] { requirement }); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/IRouteMap.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | 3 | namespace RiskFirst.Hateoas 4 | { 5 | public interface IRouteMap 6 | { 7 | RouteInfo GetCurrentRoute(); 8 | RouteInfo GetRoute(string name); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/Implementation/PagingLinksRequirement.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using Microsoft.AspNetCore.Http; 3 | using RiskFirst.Hateoas.Models; 4 | using System; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | 8 | namespace RiskFirst.Hateoas.Implementation 9 | { 10 | public class PagingLinksRequirement : LinksHandler>, ILinksRequirement 11 | { 12 | public const string QueryStringFormat = "?pagenumber={0}&pagesize={1}"; 13 | 14 | public PagingLinksRequirement() 15 | { 16 | 17 | } 18 | public string CurrentId { get; set; } 19 | public string NextId { get; set; } 20 | public string PreviousId { get; set; } 21 | public LinkCondition Condition { get; set; } = LinkCondition.None; 22 | 23 | protected override async Task HandleRequirementAsync(LinksHandlerContext context, PagingLinksRequirement requirement) 24 | { 25 | var condition = requirement.Condition; 26 | if (!context.AssertAll(condition)) 27 | { 28 | context.Skipped(requirement, LinkRequirementSkipReason.Assertion); 29 | return; 30 | } 31 | 32 | var pagingResource = context.Resource as IPagedLinkContainer; 33 | if (pagingResource == null) 34 | throw new InvalidOperationException($"PagingLinkRequirement can only be used by a resource of type IPageLinkContainer. Type: {context.Resource.GetType().FullName}"); 35 | 36 | var route = context.CurrentRoute; 37 | var values = context.CurrentRouteValues; 38 | var queryParams = context.CurrentQueryValues; 39 | 40 | if (condition != null && condition.RequiresAuthorization) 41 | { 42 | if (!await context.AuthorizeAsync(route, values, condition)) 43 | { 44 | context.Skipped(requirement, LinkRequirementSkipReason.Authorization); 45 | return; 46 | } 47 | } 48 | 49 | context.Links.Add(new LinkSpec(requirement.CurrentId, route, GetPageValues(values, queryParams, pagingResource.PageNumber, pagingResource.PageSize))); 50 | 51 | var addPrevLink = ShouldAddPreviousPageLink(pagingResource.PageNumber); 52 | var addNextLink = ShouldAddNextPageLink(pagingResource.PageNumber, pagingResource.PageCount); 53 | if (addPrevLink) 54 | { 55 | context.Links.Add(new LinkSpec(requirement.PreviousId, route, GetPageValues(values, queryParams, pagingResource.PageNumber - 1, pagingResource.PageSize))); 56 | } 57 | if (addNextLink) 58 | { 59 | context.Links.Add(new LinkSpec(requirement.NextId, route, GetPageValues(values, queryParams, pagingResource.PageNumber + 1, pagingResource.PageSize))); 60 | } 61 | context.Handled(requirement); 62 | return; 63 | } 64 | 65 | private RouteValueDictionary GetPageValues(object values, IQueryCollection queryValues, int pageNumber, int pageSize) 66 | { 67 | var newValues = new RouteValueDictionary(values); 68 | if (queryValues != null) 69 | { 70 | foreach (var queryValue in queryValues?.Where(q => q.Key != "pagenumber" && q.Key != "pagesize")) 71 | { 72 | newValues.Add(queryValue.Key, queryValue.Value); 73 | } 74 | } 75 | newValues.Add("pagenumber", pageNumber); 76 | newValues.Add("pagesize", pageSize); 77 | return newValues; 78 | } 79 | 80 | private bool ShouldAddPreviousPageLink(int pageNumber) 81 | { 82 | return pageNumber > 1; 83 | } 84 | 85 | private bool ShouldAddNextPageLink(int pageNumber, int pageCount) 86 | { 87 | return pageNumber < pageCount; 88 | } 89 | 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/Implementation/PassThroughLinksHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Implementation 7 | { 8 | public class PassThroughLinksHandler : ILinksHandler 9 | { 10 | public async Task HandleAsync(LinksHandlerContext context) 11 | { 12 | foreach (var handler in context.Requirements.OfType()) 13 | { 14 | if (context.PendingRequirements.Contains((ILinksRequirement)handler)) 15 | await handler.HandleAsync(context); 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/Implementation/RouteLinkRequirement.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using System; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Implementation 7 | { 8 | public class RouteLinkRequirement : LinksHandler>, ILinksRequirement 9 | { 10 | public RouteLinkRequirement() 11 | { 12 | 13 | } 14 | public string Id { get; set; } 15 | public string RouteName { get; set; } 16 | public Func GetRouteValues { get; set; } 17 | public LinkCondition Condition { get; set; } = LinkCondition.None; 18 | 19 | protected override async Task HandleRequirementAsync(LinksHandlerContext context, RouteLinkRequirement requirement) 20 | { 21 | var condition = requirement.Condition; 22 | if (!context.AssertAll(condition)) 23 | { 24 | context.Skipped(requirement, LinkRequirementSkipReason.Assertion); 25 | return; 26 | } 27 | if(String.IsNullOrEmpty(requirement.RouteName)) 28 | { 29 | context.Skipped(requirement, LinkRequirementSkipReason.Error, $"Requirement did not have a RouteName specified for link: {requirement.Id}"); 30 | return; 31 | } 32 | 33 | var route = context.RouteMap.GetRoute(requirement.RouteName); 34 | if(route == null) 35 | { 36 | context.Skipped(requirement, LinkRequirementSkipReason.Error,$"No route was found for route name: {requirement.RouteName}"); 37 | return; 38 | } 39 | var values = new RouteValueDictionary(); 40 | if (requirement.GetRouteValues != null) 41 | { 42 | values = requirement.GetRouteValues((TResource)context.Resource); 43 | } 44 | if (condition != null && condition.RequiresAuthorization) 45 | { 46 | if (!await context.AuthorizeAsync(route,values,condition)) 47 | { 48 | context.Skipped(requirement, LinkRequirementSkipReason.Authorization); 49 | return; 50 | } 51 | } 52 | 53 | context.Links.Add(new LinkSpec(requirement.Id, route, values)); 54 | context.Handled(requirement); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/Implementation/SelfLinkRequirement.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace RiskFirst.Hateoas.Implementation 4 | { 5 | public class SelfLinkRequirement : LinksHandler>, ILinksRequirement 6 | { 7 | 8 | public SelfLinkRequirement() 9 | { 10 | 11 | } 12 | 13 | public string Id { get; set; } 14 | 15 | protected override Task HandleRequirementAsync(LinksHandlerContext context, SelfLinkRequirement requirement) 16 | { 17 | var route = context.CurrentRoute; 18 | var values = context.CurrentRouteValues; 19 | 20 | context.Links.Add(new LinkSpec(requirement.Id, route, values)); 21 | context.Handled(requirement); 22 | return Task.FromResult(true); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkAuthorizationContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Routing; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Security.Claims; 6 | 7 | namespace RiskFirst.Hateoas 8 | { 9 | public class LinkAuthorizationContext 10 | { 11 | public LinkAuthorizationContext(bool shouldAuthorizeRoute, 12 | IEnumerable authorizationRequirements, 13 | IEnumerable authorizationPolicyNames, 14 | RouteInfo routeInfo, 15 | RouteValueDictionary routeValues, 16 | TResource resource, 17 | ClaimsPrincipal user) 18 | { 19 | this.ShouldAuthorizeRoute = shouldAuthorizeRoute; 20 | this.AuthorizationRequirements = new List(authorizationRequirements ?? Enumerable.Empty()).AsReadOnly(); 21 | this.AuthorizationPolicyNames = new List(authorizationPolicyNames ?? Enumerable.Empty()).AsReadOnly(); 22 | this.RouteInfo = routeInfo; 23 | this.RouteValues = routeValues; 24 | this.Resource = resource; 25 | this.User = user; 26 | } 27 | public bool ShouldAuthorizeRoute { get; } 28 | public IReadOnlyList AuthorizationRequirements { get; } 29 | public IReadOnlyList AuthorizationPolicyNames { get; } 30 | public RouteInfo RouteInfo { get; } 31 | public RouteValueDictionary RouteValues { get; } 32 | public TResource Resource { get; } 33 | public ClaimsPrincipal User { get; } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkCondition.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public class LinkCondition 9 | { 10 | public static readonly LinkCondition None = new LinkCondition(false, Enumerable.Empty>(), Enumerable.Empty(),Enumerable.Empty()); 11 | 12 | public LinkCondition(bool requiresRouteAuthorization, IEnumerable> assertions, IEnumerable requirements, IEnumerable policyNames) 13 | { 14 | this.RequiresRouteAuthorization = requiresRouteAuthorization; 15 | this.Assertions = new List>(assertions).AsReadOnly(); 16 | this.AuthorizationRequirements = new List(requirements).AsReadOnly(); 17 | this.AuthorizationPolicyNames = new List(policyNames).AsReadOnly(); 18 | } 19 | public IReadOnlyList> Assertions { get; } 20 | public IReadOnlyList AuthorizationRequirements { get; } 21 | public IReadOnlyList AuthorizationPolicyNames { get; } 22 | public bool RequiresRouteAuthorization { get; set; } 23 | public bool RequiresAuthorization => RequiresRouteAuthorization || AuthorizationRequirements.Any() || AuthorizationPolicyNames.Any(); 24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkConditionBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public class LinkConditionBuilder 9 | { 10 | 11 | private bool requiresRouteAuthorization = false; 12 | private List authRequirements = new List(); 13 | private List authPolicyNames = new List(); 14 | private IList> assertions = new List>(); 15 | public LinkConditionBuilder() 16 | { 17 | } 18 | 19 | public LinkConditionBuilder AuthorizeRoute() 20 | { 21 | this.requiresRouteAuthorization = true; 22 | return this; 23 | } 24 | public LinkConditionBuilder Authorize(params IAuthorizationRequirement [] requirements) 25 | { 26 | authRequirements.AddRange(requirements); 27 | return this; 28 | } 29 | public LinkConditionBuilder Authorize(params AuthorizationPolicy[] policies) 30 | { 31 | authRequirements.AddRange(policies.SelectMany(p => p.Requirements)); 32 | return this; 33 | } 34 | public LinkConditionBuilder Authorize(params string[] policyNames) 35 | { 36 | authPolicyNames.AddRange(policyNames); 37 | return this; 38 | } 39 | 40 | public LinkConditionBuilder Assert(Func condition) 41 | { 42 | this.assertions.Add(condition); 43 | return this; 44 | } 45 | 46 | public LinkCondition Build() 47 | { 48 | return new LinkCondition(this.requiresRouteAuthorization, this.assertions, this.authRequirements, this.authPolicyNames); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkSpec.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using System; 3 | using System.Net.Http; 4 | 5 | namespace RiskFirst.Hateoas 6 | { 7 | public interface ILinkSpec 8 | { 9 | string Id { get; } 10 | RouteInfo RouteInfo { get; } 11 | RouteValueDictionary RouteValues { get; } 12 | HttpMethod HttpMethod { get; } 13 | string RouteName { get; } 14 | string ControllerName { get; } 15 | } 16 | public struct LinkSpec : ILinkSpec 17 | { 18 | public LinkSpec(string id, RouteInfo routeInfo, RouteValueDictionary routeValues = null) 19 | { 20 | this.Id = id; 21 | this.RouteInfo = routeInfo; 22 | this.RouteValues = routeValues; 23 | } 24 | 25 | public string Id { get; } 26 | public RouteInfo RouteInfo { get; } 27 | public RouteValueDictionary RouteValues { get; } 28 | public HttpMethod HttpMethod => RouteInfo?.HttpMethod; 29 | public string RouteName => RouteInfo?.RouteName; 30 | public string ControllerName => RouteInfo.ControllerName; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkTransformationBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace RiskFirst.Hateoas 9 | { 10 | public class LinkTransformationBuilder 11 | { 12 | private IList> Transformations { get; } = new List>(); 13 | 14 | public LinkTransformationBuilder Add(string value) 15 | { 16 | Transformations.Add(ctx => value); 17 | return this; 18 | } 19 | public LinkTransformationBuilder Add(Func getValue) 20 | { 21 | Transformations.Add(getValue); 22 | return this; 23 | } 24 | 25 | public ILinkTransformation Build() 26 | { 27 | return new BuilderLinkTransformation(Transformations); 28 | } 29 | 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkTransformationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | 7 | namespace RiskFirst.Hateoas 8 | { 9 | public static class LinkTransformationBuilderExtensions 10 | { 11 | public static LinkTransformationBuilder AddProtocol(this LinkTransformationBuilder builder, string scheme = null) 12 | { 13 | return builder.Add(ctx => String.Concat(scheme ?? ctx.HttpContext.Request.Scheme, "://")); 14 | } 15 | 16 | public static LinkTransformationBuilder AddHost(this LinkTransformationBuilder builder) 17 | { 18 | return builder.Add(ctx => ctx.HttpContext.Request.Host.ToUriComponent()); 19 | } 20 | 21 | public static LinkTransformationBuilder AddRoutePath(this LinkTransformationBuilder builder) 22 | { 23 | return builder.Add(ctx => 24 | { 25 | if (string.IsNullOrEmpty(ctx.LinkSpec.RouteName)) 26 | throw new InvalidOperationException($"Invalid route specified in link specification."); 27 | 28 | var path = ctx.LinkGenerator.GetPathByRouteValues(ctx.HttpContext, ctx.LinkSpec.RouteName, ctx.LinkSpec.RouteValues); 29 | 30 | if (string.IsNullOrEmpty(path)) 31 | throw new InvalidOperationException($"Invalid path when adding route '{ctx.LinkSpec.RouteName}'. RouteValues: {string.Join(",", ctx.ActionContext.RouteData.Values.Select(x => string.Concat(x.Key, "=", x.Value)))}"); 32 | 33 | return path; 34 | }); 35 | } 36 | public static LinkTransformationBuilder AddVirtualPath(this LinkTransformationBuilder builder,string path) 37 | { 38 | return builder.AddVirtualPath(ctx => path); 39 | } 40 | public static LinkTransformationBuilder AddVirtualPath(this LinkTransformationBuilder builder,Func getPath) 41 | { 42 | return builder.Add(ctx => 43 | { 44 | var path = getPath(ctx); 45 | if (!path.StartsWith("/")) 46 | path = String.Concat("/", path); 47 | return path; 48 | }); 49 | } 50 | public static LinkTransformationBuilder AddQueryStringValues(this LinkTransformationBuilder builder, IDictionary values) 51 | { 52 | return builder.Add(ctx => 53 | { 54 | var queryString = String.Join("&", values.Select(v => $"{v.Key}={v.Value?.ToString()}")); 55 | return string.Concat("?", queryString); 56 | }); 57 | } 58 | public static LinkTransformationBuilder AddFragment(this LinkTransformationBuilder builder, Func getFragment) 59 | { 60 | return builder.Add(ctx => string.Concat("#", getFragment(ctx))); 61 | } 62 | 63 | private static RouteValueDictionary GetValuesDictionary(object values) 64 | { 65 | var routeValuesDictionary = values as RouteValueDictionary; 66 | if (routeValuesDictionary != null) 67 | { 68 | return routeValuesDictionary; 69 | } 70 | 71 | var dictionaryValues = values as IDictionary; 72 | if (dictionaryValues != null) 73 | { 74 | routeValuesDictionary = new RouteValueDictionary(); 75 | foreach (var kvp in dictionaryValues) 76 | { 77 | routeValuesDictionary.Add(kvp.Key, kvp.Value); 78 | } 79 | 80 | return routeValuesDictionary; 81 | } 82 | 83 | return new RouteValueDictionary(values); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkTransformationContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Routing; 7 | using Microsoft.AspNetCore.Mvc; 8 | 9 | namespace RiskFirst.Hateoas 10 | { 11 | public class LinkTransformationContext 12 | { 13 | public LinkTransformationContext(ILinkSpec spec, ActionContext actionContext, LinkGenerator linkGenerator) 14 | { 15 | this.LinkSpec = spec; 16 | this.ActionContext = actionContext; 17 | this.LinkGenerator = linkGenerator; 18 | } 19 | public virtual ILinkSpec LinkSpec { get; } 20 | public ActionContext ActionContext { get; } 21 | public HttpContext HttpContext => ActionContext.HttpContext; 22 | public RouteValueDictionary RouteValues => ActionContext.RouteData.Values; 23 | public LinkGenerator LinkGenerator {get;} 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinkTransformationException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace RiskFirst.Hateoas 6 | { 7 | public class LinkTransformationException : Exception 8 | { 9 | public LinkTransformationContext Context { get; } 10 | public LinkTransformationException(string message, LinkTransformationContext context) 11 | : base(message) 12 | { 13 | this.Context = context; 14 | } 15 | 16 | public LinkTransformationException(string message, Exception innerException, LinkTransformationContext context) 17 | : base(message,innerException) 18 | { 19 | this.Context = context; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace RiskFirst.Hateoas 4 | { 5 | [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = true)] 6 | public class LinksAttribute : Attribute 7 | { 8 | public string Policy { get; set; } 9 | 10 | public LinksAttribute() 11 | { 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas 7 | { 8 | public abstract class LinksHandler : ILinksHandler 9 | where TRequirement : ILinksRequirement 10 | { 11 | 12 | public async Task HandleAsync(LinksHandlerContext context) 13 | { 14 | foreach (var req in context.Requirements.OfType()) 15 | { 16 | await HandleRequirementAsync(context, req); 17 | } 18 | } 19 | 20 | protected abstract Task HandleRequirementAsync(LinksHandlerContext context, TRequirement requirement); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksHandlerContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Routing; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Http.Internal; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Security.Claims; 10 | using System.Threading.Tasks; 11 | 12 | namespace RiskFirst.Hateoas 13 | { 14 | public enum LinkRequirementSkipReason { Assertion, Authorization, Error, Custom } 15 | 16 | public class LinksHandlerContext 17 | { 18 | private HashSet pendingRequirements; 19 | private ILinkAuthorizationService authService; 20 | 21 | public LinksHandlerContext( 22 | IEnumerable requirements, 23 | IRouteMap routeMap, 24 | ILinkAuthorizationService authService, 25 | ILogger logger, 26 | ActionContext actionContext, 27 | object resource) 28 | { 29 | if (requirements == null) 30 | throw new ArgumentNullException(nameof(requirements)); 31 | if (routeMap == null) 32 | throw new ArgumentNullException(nameof(routeMap)); 33 | if (authService == null) 34 | throw new ArgumentNullException(nameof(authService)); 35 | if (logger == null) 36 | throw new ArgumentNullException(nameof(logger)); 37 | if (actionContext == null) 38 | throw new ArgumentNullException(nameof(actionContext)); 39 | if (resource == null) 40 | throw new ArgumentNullException(nameof(resource)); 41 | 42 | this.Requirements = requirements; 43 | this.RouteMap = routeMap; 44 | this.ActionContext = actionContext; 45 | this.Resource = resource; 46 | this.Logger = logger; 47 | 48 | this.authService = authService; 49 | this.pendingRequirements = new HashSet(requirements); 50 | } 51 | 52 | public ActionContext ActionContext { get; } 53 | 54 | public ILogger Logger { get; set; } 55 | 56 | public virtual object Resource { get; } 57 | 58 | public virtual IEnumerable Requirements { get; } 59 | 60 | public virtual HashSet PendingRequirements => this.pendingRequirements; 61 | 62 | public virtual IRouteMap RouteMap { get; } 63 | 64 | public ClaimsPrincipal User => ActionContext?.HttpContext?.User; 65 | 66 | public RouteInfo CurrentRoute => RouteMap.GetCurrentRoute(); 67 | public RouteValueDictionary CurrentRouteValues => ActionContext?.RouteData?.Values; 68 | public IQueryCollection CurrentQueryValues => ActionContext?.HttpContext?.Request?.Query; 69 | 70 | public virtual IList Links { get; } = new List(); 71 | 72 | public virtual bool AssertAll(LinkCondition condition) 73 | { 74 | if (condition == null) 75 | throw new ArgumentNullException(nameof(condition)); 76 | return !condition.Assertions.Any() || condition.Assertions.All(a => a((TResource)this.Resource)); 77 | } 78 | 79 | public virtual async Task AuthorizeAsync(RouteInfo route, RouteValueDictionary values, LinkCondition condition) 80 | { 81 | if (route == null) 82 | throw new ArgumentNullException(nameof(route)); 83 | if (values == null) 84 | throw new ArgumentNullException(nameof(values)); 85 | if (condition == null) 86 | throw new ArgumentNullException(nameof(condition)); 87 | 88 | var authContext = new LinkAuthorizationContext( 89 | condition.RequiresRouteAuthorization, 90 | condition.AuthorizationRequirements, 91 | condition.AuthorizationPolicyNames, 92 | route, 93 | values, 94 | (TResource)this.Resource, 95 | this.User); 96 | 97 | return await authService.AuthorizeLink(authContext); 98 | } 99 | 100 | public virtual void Handled(ILinksRequirement requirement) 101 | { 102 | pendingRequirements.Remove(requirement); 103 | } 104 | public virtual void Skipped(ILinksRequirement requirement, LinkRequirementSkipReason reason = LinkRequirementSkipReason.Custom, string message = null) 105 | { 106 | var username = User?.Identity?.Name ?? "Unknown"; 107 | Logger.LogInformation("Link {Requirement} skipped for user {User}. Reason: {LinkSkipReason}. {Message}.", requirement, username, reason,message ?? String.Empty); 108 | pendingRequirements.Remove(requirement); 109 | } 110 | 111 | public virtual bool IsSuccess() 112 | { 113 | return pendingRequirements.Count == 0; 114 | } 115 | 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksOptions.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | namespace RiskFirst.Hateoas 6 | { 7 | public class LinksOptions 8 | { 9 | private ILinkTransformation hrefTransformation = new LinkTransformationBuilder().AddProtocol().AddHost().AddRoutePath().Build(); 10 | private ILinkTransformation relTransformation = new LinkTransformationBuilder().Add(ctx => $"{ctx.LinkSpec.ControllerName}/{ctx.LinkSpec.RouteName}").Build(); 11 | public ILinksPolicy DefaultPolicy { get; set; } = new LinksPolicyBuilder().RequireSelfLink().Build(); 12 | public ILinkTransformation HrefTransformation => hrefTransformation; 13 | public ILinkTransformation RelTransformation => relTransformation; 14 | private IDictionary PolicyMap { get; } = new Dictionary(); 15 | 16 | public void AddPolicy(Action> configurePolicy) //where TResource : class 17 | { 18 | AddPolicy("Default", configurePolicy); 19 | } 20 | 21 | public void AddPolicy(ILinksPolicy policy) 22 | { 23 | AddPolicy("Default", policy); 24 | } 25 | 26 | public void AddPolicy(string name, ILinksPolicy policy) 27 | { 28 | if (String.IsNullOrEmpty(name)) 29 | throw new ArgumentException("Policy name cannot be null.", nameof(name)); 30 | 31 | if (policy == null) 32 | throw new ArgumentNullException(nameof(policy)); 33 | 34 | var policyName = ConstructFullPolicyName(name); 35 | PolicyMap[policyName] = policy; 36 | } 37 | 38 | public void AddPolicy(string name, Action> configurePolicy) //where TResource : class 39 | { 40 | if (configurePolicy == null) 41 | throw new ArgumentNullException(nameof(configurePolicy)); 42 | 43 | var builder = new LinksPolicyBuilder(); 44 | configurePolicy(builder); 45 | 46 | AddPolicy(name, builder.Build()); 47 | } 48 | 49 | public void ConfigureHrefTransformation(Action configureTransform) 50 | { 51 | var builder = new LinkTransformationBuilder(); 52 | configureTransform(builder); 53 | this.hrefTransformation = builder.Build(); 54 | } 55 | 56 | public void ConfigureRelTransformation(Action configureTransform) 57 | { 58 | var builder = new LinkTransformationBuilder(); 59 | configureTransform(builder); 60 | this.relTransformation = builder.Build(); 61 | } 62 | 63 | public ILinksPolicy GetPolicy() 64 | { 65 | return GetPolicy("Default"); 66 | } 67 | 68 | public ILinksPolicy GetPolicy(string name) 69 | { 70 | if (String.IsNullOrEmpty(name)) 71 | throw new ArgumentException("Policy name cannot be null", nameof(name)); 72 | var policyName = $"{name}:{typeof(TResource).FullName}"; 73 | return PolicyMap.ContainsKey(policyName) ? PolicyMap[policyName] as ILinksPolicy/**/ : null; 74 | } 75 | 76 | public void UseHrefTransformation() 77 | where T : ILinkTransformation, new() 78 | { 79 | UseHrefTransformation(new T()); 80 | } 81 | 82 | public void UseHrefTransformation(T transform) 83 | where T : ILinkTransformation 84 | { 85 | this.hrefTransformation = transform; 86 | } 87 | 88 | public void UseRelativeHrefs() 89 | { 90 | hrefTransformation = new LinkTransformationBuilder().AddRoutePath().Build(); 91 | } 92 | 93 | public void UseRelTransformation() 94 | where T : ILinkTransformation, new() 95 | { 96 | UseRelTransformation(new T()); 97 | } 98 | 99 | public void UseRelTransformation(T transform) 100 | where T : ILinkTransformation 101 | { 102 | this.relTransformation = transform; 103 | } 104 | 105 | private static string ConstructFullPolicyName(string name) 106 | { 107 | return $"{name}:{typeof(TResource).FullName}"; 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksPolicy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace RiskFirst.Hateoas 5 | { 6 | public class LinksPolicy : ILinksPolicy 7 | { 8 | public LinksPolicy(IEnumerable requirements) 9 | { 10 | if (requirements == null) 11 | throw new ArgumentNullException(nameof(requirements)); 12 | 13 | this.Requirements = new List(requirements).AsReadOnly(); 14 | } 15 | public IReadOnlyList Requirements { get; } 16 | 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksPolicyBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using RiskFirst.Hateoas.Implementation; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Reflection; 6 | 7 | namespace RiskFirst.Hateoas 8 | { 9 | public class LinksPolicyBuilder 10 | { 11 | public LinksPolicyBuilder() 12 | { 13 | } 14 | private IList Requirements { get; } = new List*/>(); 15 | 16 | public LinksPolicyBuilder Combine(ILinksPolicy policy) 17 | { 18 | foreach(var requirement in policy.Requirements) 19 | { 20 | Requirements.Add(requirement); 21 | } 22 | return this; 23 | } 24 | 25 | public LinksPolicyBuilder Requires() 26 | where TRequirement : ILinksRequirement, new() 27 | { 28 | Requirements.Add(new TRequirement()); 29 | return this; 30 | } 31 | public LinksPolicyBuilder Requires(TRequirement requirement) 32 | where TRequirement : ILinksRequirement 33 | { 34 | Requirements.Add(requirement); 35 | return this; 36 | } 37 | 38 | public LinksPolicy Build() 39 | { 40 | return new LinksPolicy(Requirements); 41 | } 42 | 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksPolicyBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using RiskFirst.Hateoas.Implementation; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | 8 | namespace RiskFirst.Hateoas 9 | { 10 | public static class LinksPolicyBuilderExtensions 11 | { 12 | public static LinksPolicyBuilder RequireSelfLink(this LinksPolicyBuilder builder) 13 | { 14 | return builder.RequireSelfLink("self"); 15 | } 16 | public static LinksPolicyBuilder RequireSelfLink(this LinksPolicyBuilder builder, 17 | string id) 18 | { 19 | return builder.Requires(new SelfLinkRequirement() 20 | { 21 | Id = id 22 | }); 23 | } 24 | 25 | public static LinksPolicyBuilder RequireRoutedLink(this LinksPolicyBuilder builder, 26 | string id, 27 | string routeName) 28 | { 29 | return builder.RequireRoutedLink(id, routeName, null, condition: null); 30 | } 31 | 32 | public static LinksPolicyBuilder RequireRoutedLink(this LinksPolicyBuilder builder, 33 | string id, 34 | string routeName, 35 | Func getValues, 36 | Action> configureCondition) 37 | { 38 | var conditionBuilder = new LinkConditionBuilder(); 39 | configureCondition?.Invoke(conditionBuilder); 40 | return builder.RequireRoutedLink(id, routeName, getValues: getValues, condition: conditionBuilder.Build()); 41 | } 42 | 43 | public static LinksPolicyBuilder RequireRoutedLink(this LinksPolicyBuilder builder, 44 | string id, 45 | string routeName, 46 | Func getValues, 47 | LinkCondition condition = null) 48 | { 49 | Func getRouteValues = r => new RouteValueDictionary(); 50 | if (getValues != null) 51 | getRouteValues = r => new RouteValueDictionary(getValues(r)); 52 | var req = new RouteLinkRequirement() 53 | { 54 | Id = id, 55 | RouteName = routeName, 56 | GetRouteValues = getRouteValues, 57 | Condition = condition ?? LinkCondition.None 58 | }; 59 | return builder.Requires(req); 60 | } 61 | public static LinksPolicyBuilder RequiresPagingLinks(this LinksPolicyBuilder builder) 62 | { 63 | return builder.RequiresPagingLinks("currentPage", "nextPage", "previousPage"); 64 | } 65 | public static LinksPolicyBuilder RequiresPagingLinks(this LinksPolicyBuilder builder, 66 | string currentId, 67 | string nextId, 68 | string previousId) 69 | { 70 | return builder.RequiresPagingLinks(currentId, nextId, previousId, condition: null); 71 | } 72 | public static LinksPolicyBuilder RequiresPagingLinks(this LinksPolicyBuilder builder, 73 | Action> configureCondition) 74 | { 75 | return builder.RequiresPagingLinks("currentPage", "nextPage", "previousPage",configureCondition); 76 | } 77 | public static LinksPolicyBuilder RequiresPagingLinks(this LinksPolicyBuilder builder, 78 | string currentId, 79 | string nextId, 80 | string previousId, 81 | Action> configureCondition) 82 | { 83 | var conditionBuilder = new LinkConditionBuilder(); 84 | configureCondition?.Invoke(conditionBuilder); 85 | return builder. RequiresPagingLinks(currentId, nextId, previousId, condition: conditionBuilder.Build()); 86 | } 87 | 88 | private static LinksPolicyBuilder RequiresPagingLinks(this LinksPolicyBuilder builder, 89 | string currentId, 90 | string nextId, 91 | string previousId, 92 | LinkCondition condition) 93 | { 94 | var req = new PagingLinksRequirement() 95 | { 96 | CurrentId = currentId, 97 | NextId = nextId, 98 | PreviousId = previousId, 99 | Condition = condition ?? LinkCondition.None 100 | }; 101 | return builder.Requires(req); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/LinksServicesCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc.Infrastructure; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.DependencyInjection.Extensions; 5 | using System; 6 | using RiskFirst.Hateoas.Polyfills; 7 | 8 | namespace RiskFirst.Hateoas 9 | { 10 | public static class LinksServicesCollectionExtensions 11 | { 12 | public static IServiceCollection AddLinks(this IServiceCollection services) 13 | { 14 | if (services == null) 15 | throw new ArgumentNullException(nameof(services)); 16 | 17 | services.TryAddSingleton(); 18 | services.TryAddSingleton(); 19 | services.TryAdd(ServiceDescriptor.Singleton()); 20 | services.TryAdd(ServiceDescriptor.Transient()); 21 | services.TryAdd(ServiceDescriptor.Transient()); 22 | services.TryAdd(ServiceDescriptor.Transient()); 23 | services.TryAdd(ServiceDescriptor.Transient()); 24 | services.TryAdd(ServiceDescriptor.Transient()); 25 | services.TryAdd(ServiceDescriptor.Transient()); 26 | services.TryAddEnumerable(ServiceDescriptor.Transient()); 27 | return services; 28 | } 29 | 30 | public static IServiceCollection AddLinks(this IServiceCollection services, Action configure) 31 | { 32 | if (services == null) 33 | throw new ArgumentNullException(nameof(services)); 34 | if (configure == null) 35 | throw new ArgumentNullException(nameof(configure)); 36 | 37 | services.Configure(configure); 38 | return services.AddLinks(); ; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/Polyfills/DefaultAssemblyLoader.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Reflection; 4 | using Microsoft.Extensions.DependencyModel; 5 | 6 | namespace RiskFirst.Hateoas.Polyfills 7 | { 8 | /// 9 | /// 10 | /// Default implementation for .Net Standard 11 | /// 12 | /// 13 | internal class DefaultAssemblyLoader : IAssemblyLoader 14 | { 15 | public IEnumerable GetAssemblies() 16 | { 17 | var thisAssembly = GetType().GetTypeInfo().Assembly.GetName().Name; 18 | var libraries = 19 | DependencyContext.Default 20 | .CompileLibraries 21 | .Where(l => l.Dependencies.Any(d => d.Name.Equals(thisAssembly))); 22 | 23 | var names = libraries.Select(l => l.Name).Distinct(); 24 | var assemblies = names.Select(a => Assembly.Load(new AssemblyName(a))); 25 | return assemblies; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/Polyfills/IAssemblyLoader.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Reflection; 3 | 4 | namespace RiskFirst.Hateoas.Polyfills 5 | { 6 | /// 7 | /// Abstracts AppDomain functionallity 8 | /// 9 | public interface IAssemblyLoader 10 | { 11 | IEnumerable GetAssemblies(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyConfiguration("")] 9 | [assembly: AssemblyCompany("RiskFirst")] 10 | [assembly: AssemblyProduct("RiskFirst.Hateos")] 11 | [assembly: AssemblyTrademark("")] 12 | 13 | // Setting ComVisible to false makes the types in this assembly not visible 14 | // to COM components. If you need to access a type in this assembly from 15 | // COM, set the ComVisible attribute to true on that type. 16 | [assembly: ComVisible(false)] 17 | 18 | // The following GUID is for the ID of the typelib if this project is exposed to COM 19 | [assembly: Guid("ad523ab1-1548-4a2e-80f3-7b2997c60d65")] 20 | 21 | [assembly: InternalsVisibleTo("RiskFirst.Hateoas.Tests")] 22 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/ReflectionControllerMethodInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Threading.Tasks; 6 | 7 | namespace RiskFirst.Hateoas 8 | { 9 | public class ReflectionControllerMethodInfo : IControllerMethodInfo 10 | { 11 | private readonly MethodInfo methodInfo; 12 | public ReflectionControllerMethodInfo(MethodInfo methodInfo) 13 | { 14 | this.methodInfo = methodInfo; 15 | } 16 | public Type ControllerType => methodInfo.DeclaringType; 17 | 18 | public Type ReturnType => methodInfo.ReturnType; 19 | 20 | public string MethodName => methodInfo.Name; 21 | 22 | public IEnumerable GetAttributes() where TAttribute : Attribute 23 | { 24 | return methodInfo.GetCustomAttributes() 25 | .Union(ControllerType.GetTypeInfo().GetCustomAttributes()); 26 | } 27 | 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/RiskFirst.Hateoas.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0 5 | RiskFirst.Hateoas 6 | RiskFirst.Hateoas 7 | false 8 | false 9 | false 10 | False 11 | https://github.com/riskfirst/riskfirst.hateoas 12 | RiskFirst 13 | https://github.com/riskfirst/riskfirst.hateoas/blob/master/LICENSE 14 | An implementation of HATEOAS for aspnet core web api projects which gives full control of which links to apply to models returned from your api. 15 | https://github.com/riskfirst/riskfirst.hateoas 16 | GIT 17 | HATEOAS aspnet dotnetcore 18 | 19 | v0.0.1 - Initial release 20 | v1.0.0 - First stable release 21 | v1.0.1 - Bug fixes to 1.0.0 22 | v1.1.0 - Refactored a lot of the library to simplify the use of generics, enabling implementation of ability to Combine policies. 23 | v1.1.1 - Support for .NET Framework 4.5.1 24 | v1.1.2 - Changed dependent assembly loading procedure for Controllers. 25 | v1.1.3 - Implemented interface to allow control assembly loading 26 | v2.0.0 - Upgrade framework and dependencies to netstandard2/net461 27 | v3.0.0 - extract models to separate assembly 28 | v3.1.0 - netcore package reference upgrade to 2.1 29 | v3.1.1 - bug fix for routes with more than 1 HttpMethodAttribute 30 | v3.1.2 - Patch Security Issues 31 | v4.0.0 - Drop .NET Framework support 32 | 33 | 4.0.0 34 | https://raw.githubusercontent.com/riskfirst/pkgicons/master/riskfirst-pkg.png 35 | 4.0.0.0 36 | 4.0.0.0 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/RiskFirst.Hateoas/RouteInfo.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Reflection; 7 | 8 | namespace RiskFirst.Hateoas 9 | { 10 | public class RouteInfo 11 | { 12 | public RouteInfo(string name, HttpMethod httpMethod, IControllerMethodInfo methodInfo) 13 | { 14 | this.RouteName = name ?? $"{methodInfo?.ControllerType.Namespace}.{methodInfo?.ControllerType?.Name}.{methodInfo?.MethodName}"; 15 | this.HttpMethod = httpMethod; 16 | this.MethodInfo = methodInfo; 17 | } 18 | public string RouteName { get; } 19 | public HttpMethod HttpMethod { get; } 20 | public IControllerMethodInfo MethodInfo { get; } 21 | public Type ControllerType => MethodInfo?.ControllerType; 22 | public string MethodName => MethodInfo?.MethodName; 23 | public Type ReturnType => MethodInfo.ReturnType; 24 | public string ControllerName => ControllerType?.Name?.Replace("Controller", String.Empty); 25 | public IEnumerable LinksAttributes => MethodInfo?.GetAttributes(); 26 | public IEnumerable AuthorizeAttributes => MethodInfo?.GetAttributes(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Controllers/ApiController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using RiskFirst.Hateoas.Models; 3 | using RiskFirst.Hateoas.Tests.Controllers.Models; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Tests.Controllers 7 | { 8 | [ApiController] 9 | public class ApiController : ControllerBase 10 | { 11 | public const string GetAllValuesRoute = "GetAllValuesApiRoute"; 12 | 13 | // GET api/values 14 | [HttpGet(Name = "GetAllValuesApiRoute")] 15 | public Task> Get() 16 | { 17 | return Task.FromResult(new ItemsLinkContainer()); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Controllers/Models/ValueInfo.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | 3 | namespace RiskFirst.Hateoas.Tests.Controllers.Models 4 | { 5 | public class ValueInfo : LinkContainer 6 | { 7 | public int Id { get; set; } 8 | public string Value { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Controllers/MvcController.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Mvc; 3 | using RiskFirst.Hateoas.Models; 4 | using RiskFirst.Hateoas.Tests.Controllers.Models; 5 | 6 | namespace RiskFirst.Hateoas.Tests.Controllers 7 | { 8 | [Route("api/[controller]")] 9 | public class MvcController : Controller 10 | { 11 | public const string GetAllValuesRoute = "GetAllValuesRoute"; 12 | 13 | // GET api/values 14 | [HttpGet(Name = "GetAllValuesRoute")] 15 | public Task> Get() 16 | { 17 | return Task.FromResult(new ItemsLinkContainer()); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/DefaultLinkAuthorizationServiceTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Routing; 3 | using Moq; 4 | using RiskFirst.Hateoas.Tests.Infrastructure; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Net.Http; 9 | using System.Security.Claims; 10 | using System.Threading.Tasks; 11 | using Xunit; 12 | 13 | namespace RiskFirst.Hateoas.Tests 14 | { 15 | [Trait("Category","Authorization")] 16 | public class DefaultLinkAuthorizationServiceTests 17 | { 18 | private LinksAuthorizationTestCase ConfigureTestCase(Action configureTest = null) 19 | { 20 | var builder = new TestCaseBuilder(); 21 | configureTest?.Invoke(builder); 22 | return builder.BuildAuthorizationServiceTestCase(); 23 | } 24 | 25 | [AutonamedFact] 26 | public async Task AuthService_GivenUnauthenticatedUser_DoesNotAuthorize() 27 | { 28 | var testCase = ConfigureTestCase(builder => 29 | { 30 | builder.WithIdentity(user => 31 | { 32 | user.SetupGet(x => x.IsAuthenticated) 33 | .Returns(false); 34 | }) 35 | .WithMockAuthorizationService() 36 | .WithMockRouteMap(routeMap => 37 | { 38 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 39 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, new Mock().Object)); 40 | }); 41 | }); 42 | 43 | var model = new TestLinkContainer(); 44 | var context = testCase.CreateContext(model, true); 45 | var result = await testCase.UnderTest.AuthorizeLink(context); 46 | 47 | Assert.False(result, "Expected authorization deny"); 48 | } 49 | 50 | [AutonamedFact] 51 | public async Task AuthService_GivenRouteAuthorizationWithRole_DoesNotAuthorize() 52 | { 53 | var testCase = ConfigureTestCase(builder => 54 | { 55 | builder.WithIdentity(user => 56 | { 57 | user.SetupGet(x => x.IsAuthenticated) 58 | .Returns(true); 59 | }) 60 | .WithMockAuthorizationService() 61 | .WithMockRouteMap(routeMap => 62 | { 63 | var methodInfoMock = new Mock(); 64 | methodInfoMock.Setup(x => x.GetAttributes()) 65 | .Returns(new[] { new AuthorizeAttribute() { Roles = "Role1" } }); 66 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 67 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 68 | }); 69 | }); 70 | 71 | var model = new TestLinkContainer(); 72 | var context = testCase.CreateContext(model, true); 73 | var result = await testCase.UnderTest.AuthorizeLink(context); 74 | 75 | Assert.False(result, "Expected authorization deny"); 76 | } 77 | 78 | [AutonamedFact] 79 | public async Task AuthService_GivenRouteAuthorizationWithRole_DoesAuthorize() 80 | { 81 | var testCase = ConfigureTestCase(builder => 82 | { 83 | builder.WithIdentity(user => 84 | { 85 | user.SetupGet(x => x.IsAuthenticated) 86 | .Returns(true); 87 | }) 88 | .WithPrincipal(principal => 89 | { 90 | principal.Setup(x => x.IsInRole("Role1")) 91 | .Returns(true); 92 | }) 93 | .WithMockAuthorizationService() 94 | .WithMockRouteMap(routeMap => 95 | { 96 | var methodInfoMock = new Mock(); 97 | methodInfoMock.Setup(x => x.GetAttributes()) 98 | .Returns(new[] { new AuthorizeAttribute() { Roles = "Role1" } }); 99 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 100 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 101 | }); 102 | }); 103 | 104 | var model = new TestLinkContainer(); 105 | var context = testCase.CreateContext(model, true); 106 | var result = await testCase.UnderTest.AuthorizeLink(context); 107 | 108 | Assert.True(result, "Expected authorization grant"); 109 | } 110 | 111 | [AutonamedFact] 112 | public async Task AuthService_GivenRouteAuthorizationWithPolicy_DoesNotAuthorize() 113 | { 114 | var testCase = ConfigureTestCase(builder => 115 | { 116 | builder.WithIdentity(user => 117 | { 118 | user.SetupGet(x => x.IsAuthenticated) 119 | .Returns(true); 120 | }) 121 | .WithMockAuthorizationService(authSvc => 122 | { 123 | authSvc.Setup(x => x.AuthorizeAsync(It.IsAny(), It.IsAny(), "Policy1")) 124 | .Returns(Task.FromResult(AuthorizationResult.Failed())); 125 | }) 126 | .WithMockRouteMap(routeMap => 127 | { 128 | var methodInfoMock = new Mock(); 129 | methodInfoMock.Setup(x => x.GetAttributes()) 130 | .Returns(new[] { new AuthorizeAttribute("Policy1") }); 131 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 132 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 133 | }); 134 | }); 135 | 136 | var model = new TestLinkContainer(); 137 | var context = testCase.CreateContext(model, true); 138 | var result = await testCase.UnderTest.AuthorizeLink(context); 139 | 140 | Assert.False(result, "Expected authorization deny"); 141 | } 142 | 143 | [AutonamedFact] 144 | public async Task AuthService_GivenRouteAuthorizationWithPolicy_DoesAuthorize() 145 | { 146 | var testCase = ConfigureTestCase(builder => 147 | { 148 | builder.WithIdentity(user => 149 | { 150 | user.SetupGet(x => x.IsAuthenticated) 151 | .Returns(true); 152 | }) 153 | .WithMockAuthorizationService(authSvc => 154 | { 155 | authSvc.Setup(x => x.AuthorizeAsync(It.IsAny(), It.IsAny(), "Policy1")) 156 | .Returns(Task.FromResult(AuthorizationResult.Success())); 157 | }) 158 | .WithMockRouteMap(routeMap => 159 | { 160 | var methodInfoMock = new Mock(); 161 | methodInfoMock.Setup(x => x.GetAttributes()) 162 | .Returns(new[] { new AuthorizeAttribute("Policy1") }); 163 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 164 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 165 | }); 166 | }); 167 | 168 | var model = new TestLinkContainer(); 169 | var context = testCase.CreateContext(model, true); 170 | var result = await testCase.UnderTest.AuthorizeLink(context); 171 | 172 | Assert.True(result, "Expected authorization grant"); 173 | } 174 | 175 | [AutonamedFact] 176 | public async Task AuthService_GivenRequirements_DoesNotAuthorize() 177 | { 178 | var testCase = ConfigureTestCase(builder => 179 | { 180 | builder.WithIdentity(user => 181 | { 182 | user.SetupGet(x => x.IsAuthenticated) 183 | .Returns(true); 184 | }) 185 | .WithMockAuthorizationService(authSvc => 186 | { 187 | authSvc.Setup(x => x.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny>())) 188 | .Returns(Task.FromResult(AuthorizationResult.Failed())); 189 | }) 190 | .WithMockRouteMap(routeMap => 191 | { 192 | var methodInfoMock = new Mock(); 193 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 194 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 195 | }); 196 | }); 197 | 198 | var model = new TestLinkContainer(); 199 | var context = testCase.CreateContext(model, false,new[] { new DummyAuthorizationRequirement() } ); 200 | var result = await testCase.UnderTest.AuthorizeLink(context); 201 | 202 | Assert.False(result, "Expected authorization deny"); 203 | } 204 | 205 | [AutonamedFact] 206 | public async Task AuthService_GivenRequirements_DoesAuthorize() 207 | { 208 | var testCase = ConfigureTestCase(builder => 209 | { 210 | builder.WithIdentity(user => 211 | { 212 | user.SetupGet(x => x.IsAuthenticated) 213 | .Returns(true); 214 | }) 215 | .WithMockAuthorizationService(authSvc => 216 | { 217 | authSvc.Setup(x => x.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny>())) 218 | .Returns(Task.FromResult(AuthorizationResult.Success())); 219 | }) 220 | .WithMockRouteMap(routeMap => 221 | { 222 | var methodInfoMock = new Mock(); 223 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 224 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 225 | }); 226 | }); 227 | 228 | var model = new TestLinkContainer(); 229 | var context = testCase.CreateContext(model, false, new[] { new DummyAuthorizationRequirement() }); 230 | var result = await testCase.UnderTest.AuthorizeLink(context); 231 | 232 | Assert.True(result, "Expected authorization grant"); 233 | } 234 | 235 | [AutonamedFact] 236 | public async Task AuthService_GivenNamedPolicy_DoesNotAuthorize() 237 | { 238 | var testCase = ConfigureTestCase(builder => 239 | { 240 | builder.WithIdentity(user => 241 | { 242 | user.SetupGet(x => x.IsAuthenticated) 243 | .Returns(true); 244 | }) 245 | .WithMockAuthorizationService(authSvc => 246 | { 247 | authSvc.Setup(x => x.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny())) 248 | .Returns(Task.FromResult(AuthorizationResult.Failed())); 249 | }) 250 | .WithMockRouteMap(routeMap => 251 | { 252 | var methodInfoMock = new Mock(); 253 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 254 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 255 | }); 256 | }); 257 | 258 | var model = new TestLinkContainer(); 259 | var context = testCase.CreateContext(model, false, null, new[] { "Policy1" }); 260 | var result = await testCase.UnderTest.AuthorizeLink(context); 261 | 262 | Assert.False(result, "Expected authorization deny"); 263 | } 264 | 265 | [AutonamedFact] 266 | public async Task AuthService_GivenNamedPolicy_DoesAuthorize() 267 | { 268 | var testCase = ConfigureTestCase(builder => 269 | { 270 | builder.WithIdentity(user => 271 | { 272 | user.SetupGet(x => x.IsAuthenticated) 273 | .Returns(true); 274 | }) 275 | .WithMockAuthorizationService(authSvc => 276 | { 277 | authSvc.Setup(x => x.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny())) 278 | .Returns(Task.FromResult(AuthorizationResult.Success())); 279 | }) 280 | .WithMockRouteMap(routeMap => 281 | { 282 | var methodInfoMock = new Mock(); 283 | routeMap.Setup(x => x.GetRoute(It.IsAny())) 284 | .Returns(routeName => new RouteInfo(routeName, HttpMethod.Get, methodInfoMock.Object)); 285 | }); 286 | }); 287 | 288 | var model = new TestLinkContainer(); 289 | var context = testCase.CreateContext(model, false, null, new[] { "Policy1" }); 290 | var result = await testCase.UnderTest.AuthorizeLink(context); 291 | 292 | Assert.True(result, "Expected authorization grant"); 293 | } 294 | 295 | private class DummyAuthorizationRequirement : IAuthorizationRequirement 296 | { 297 | 298 | } 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/DefaultLinksEvaluatorTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using Moq; 3 | using RiskFirst.Hateoas.Tests.Infrastructure; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Net.Http; 8 | using System.Threading.Tasks; 9 | using Xunit; 10 | 11 | namespace RiskFirst.Hateoas.Tests 12 | { 13 | [Trait("Category","Evaluation")] 14 | public class DefaultLinksEvaluatorTests 15 | { 16 | private LinksEvaluatorTestCase ConfigureTestCase(Action configureTest) 17 | { 18 | var builder = new TestCaseBuilder(); 19 | configureTest?.Invoke(builder); 20 | return builder.BuildLinksEvaluatorTestCase(); 21 | } 22 | 23 | [AutonamedFact] 24 | public void GivenMockTransformations_TransformIsExecuted() 25 | { 26 | // Arrange 27 | var testCase = ConfigureTestCase(builder => 28 | { 29 | builder.UseMockHrefTransformation(config => 30 | { 31 | config.Setup(x => x.Transform(It.IsAny())).Returns("href"); 32 | }) 33 | .UseMockRelTransformation(config => 34 | { 35 | config.Setup(x => x.Transform(It.IsAny())).Returns("rel"); 36 | }); 37 | }); 38 | var mockLinkSpec = new Mock(); 39 | mockLinkSpec.SetupGet(x => x.Id).Returns("testLink"); 40 | mockLinkSpec.SetupGet(x => x.HttpMethod).Returns(HttpMethod.Get); 41 | 42 | // Act 43 | var model = new TestLinkContainer(); 44 | testCase.UnderTest.BuildLinks(new[] { mockLinkSpec.Object }, model); 45 | 46 | // Assert 47 | Assert.True(model.Links.Count == 1, "Incorrect number of links applied"); 48 | Assert.Equal("href", model.Links["testLink"].Href); 49 | Assert.Equal("rel", model.Links["testLink"].Rel); 50 | 51 | testCase.HrefTransformMock.Verify(x => x.Transform(It.IsAny()), Times.Once()); 52 | testCase.RelTransformMock.Verify(x => x.Transform(It.IsAny()), Times.Once()); 53 | 54 | } 55 | 56 | [AutonamedFact] 57 | public void GivenLinkBuilderTransform_HrefIsBuilt() 58 | { 59 | // Arrange 60 | var testCase = ConfigureTestCase(builder => 61 | { 62 | builder.UseLinkBuilderHrefTransformation(href => href.Add("a").Add(ctx => "b").Add("c")) 63 | .UseMockRelTransformation(null); 64 | }); 65 | var mockLinkSpec = new Mock(); 66 | mockLinkSpec.SetupGet(x => x.Id).Returns("testLink"); 67 | mockLinkSpec.SetupGet(x => x.HttpMethod).Returns(HttpMethod.Get); 68 | 69 | // Act 70 | var model = new TestLinkContainer(); 71 | testCase.UnderTest.BuildLinks(new[] { mockLinkSpec.Object }, model); 72 | 73 | // Assert 74 | Assert.True(model.Links.Count == 1, "Incorrect number of links applied"); 75 | Assert.Equal("abc", model.Links["testLink"].Href); 76 | 77 | testCase.RelTransformMock.Verify(x => x.Transform(It.IsAny()), Times.Once()); 78 | } 79 | 80 | [AutonamedFact] 81 | public void GivenLinkBuilderTransform_UsingDefaultAddProtocol_HrefIsBuiltUsingRequestScheme() 82 | { 83 | // Arrange 84 | var testCase = ConfigureTestCase(builder => 85 | { 86 | builder.WithRequestScheme(Uri.UriSchemeHttp); 87 | builder.UseLinkBuilderHrefTransformation(href => href.AddProtocol()) 88 | .UseMockRelTransformation(null); 89 | }); 90 | var mockLinkSpec = new Mock(); 91 | mockLinkSpec.SetupGet(x => x.Id).Returns("testLink"); 92 | mockLinkSpec.SetupGet(x => x.HttpMethod).Returns(HttpMethod.Get); 93 | 94 | // Act 95 | var model = new TestLinkContainer(); 96 | testCase.UnderTest.BuildLinks(new[] { mockLinkSpec.Object }, model); 97 | 98 | // Assert 99 | Assert.Equal("http://", model.Links["testLink"].Href); 100 | 101 | testCase.RelTransformMock.Verify(x => x.Transform(It.IsAny()), Times.Once()); 102 | } 103 | 104 | [AutonamedFact] 105 | public void GivenLinkBuilderTransform_UsingOverriddenAddProtocol_HrefIsBuiltUsingProvidedScheme() 106 | { 107 | // Arrange 108 | var testCase = ConfigureTestCase(builder => 109 | { 110 | builder.WithRequestScheme(Uri.UriSchemeHttp); 111 | builder.UseLinkBuilderHrefTransformation(href => href.AddProtocol(Uri.UriSchemeHttps)) 112 | .UseMockRelTransformation(null); 113 | }); 114 | var mockLinkSpec = new Mock(); 115 | mockLinkSpec.SetupGet(x => x.Id).Returns("testLink"); 116 | mockLinkSpec.SetupGet(x => x.HttpMethod).Returns(HttpMethod.Get); 117 | 118 | // Act 119 | var model = new TestLinkContainer(); 120 | testCase.UnderTest.BuildLinks(new[] { mockLinkSpec.Object }, model); 121 | 122 | // Assert 123 | Assert.Equal("https://", model.Links["testLink"].Href); 124 | 125 | testCase.RelTransformMock.Verify(x => x.Transform(It.IsAny()), Times.Once()); 126 | } 127 | 128 | [AutonamedFact] 129 | public void GivenExceptionThrowingHrefTransformation_ThowsLinkTransformationException() 130 | { 131 | // Arrange 132 | var testCase = ConfigureTestCase(builder => 133 | { 134 | builder.UseMockHrefTransformation(mock => 135 | { 136 | mock.Setup(x => x.Transform(It.IsAny())).Throws(); 137 | }) 138 | .UseMockRelTransformation(mock => 139 | { 140 | mock.Setup(x => x.Transform(It.IsAny())).Returns("rel"); 141 | }); 142 | }); 143 | var mockLinkSpec = new Mock(); 144 | mockLinkSpec.SetupGet(x => x.Id).Returns("testLink"); 145 | mockLinkSpec.SetupGet(x => x.HttpMethod).Returns(HttpMethod.Get); 146 | 147 | // Act 148 | var model = new TestLinkContainer(); 149 | Assert.Throws(() => 150 | { 151 | testCase.UnderTest.BuildLinks(new[] { mockLinkSpec.Object }, model); 152 | }); 153 | } 154 | 155 | [AutonamedFact] 156 | public void GivenExceptionThrowingRelTransformation_ThowsLinkTransformationException() 157 | { 158 | // Arrange 159 | var testCase = ConfigureTestCase(builder => 160 | { 161 | builder.UseMockHrefTransformation(mock => 162 | { 163 | mock.Setup(x => x.Transform(It.IsAny())).Returns("href"); 164 | }) 165 | .UseLinkBuilderRelTransformation(config => 166 | { 167 | config.Add(ctx => throw new InvalidOperationException()); 168 | }); 169 | }); 170 | var mockLinkSpec = new Mock(); 171 | mockLinkSpec.SetupGet(x => x.Id).Returns("testLink"); 172 | mockLinkSpec.SetupGet(x => x.HttpMethod).Returns(HttpMethod.Get); 173 | 174 | // Act 175 | var model = new TestLinkContainer(); 176 | Assert.Throws(() => 177 | { 178 | testCase.UnderTest.BuildLinks(new[] { mockLinkSpec.Object }, model); 179 | }); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/DefaultRouteMapTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Infrastructure; 2 | using Microsoft.Extensions.Logging; 3 | using Moq; 4 | using RiskFirst.Hateoas.Polyfills; 5 | using RiskFirst.Hateoas.Tests.Controllers; 6 | using RiskFirst.Hateoas.Tests.Infrastructure; 7 | using Xunit; 8 | 9 | namespace RiskFirst.Hateoas.Tests 10 | { 11 | [Trait("Category", "RouteMap")] 12 | public class DefaultRouteMapTests 13 | { 14 | [AutonamedFact] 15 | public void GivenAssemblyLoaderProvidesControllerAssemblies_RoutesAreRegistered() 16 | { 17 | var contextAccessorMock = new Mock(); 18 | var loggerMock = new Mock>(); 19 | 20 | var routeMap = new DefaultRouteMap(contextAccessorMock.Object, loggerMock.Object, new DefaultAssemblyLoader()); 21 | var route = routeMap.GetRoute(MvcController.GetAllValuesRoute); 22 | Assert.NotNull(route); 23 | } 24 | 25 | 26 | [AutonamedFact] 27 | public void GivenAssemblyLoaderProvidesApiControllerAssemblies_RoutesAreRegistered() 28 | { 29 | var contextAccessorMock = new Mock(); 30 | var loggerMock = new Mock>(); 31 | 32 | var routeMap = new DefaultRouteMap(contextAccessorMock.Object, loggerMock.Object, new DefaultAssemblyLoader()); 33 | var route = routeMap.GetRoute(ApiController.GetAllValuesRoute); 34 | Assert.NotNull(route); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Infrastructure/Attributes.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.CompilerServices; 5 | using System.Threading.Tasks; 6 | using Xunit; 7 | using Xunit.Sdk; 8 | 9 | namespace RiskFirst.Hateoas.Tests.Infrastructure 10 | { 11 | public class AutonamedFactAttribute : FactAttribute 12 | { 13 | public AutonamedFactAttribute(string charsToReplace = "_", string replacementChars = " ", [CallerMemberName]string testMethodName = "") 14 | { 15 | if(charsToReplace != null) 16 | { 17 | base.DisplayName = testMethodName?.Replace(charsToReplace, replacementChars); 18 | } 19 | } 20 | } 21 | 22 | [XunitTestCaseDiscoverer("Xunit.Sdk.TheoryDiscoverer", "xunit.execution.{Platform}")] 23 | public class AutonamedTheoryAttribute : AutonamedFactAttribute 24 | { 25 | public AutonamedTheoryAttribute(string charsToReplace = "_", string replacementChars = " ", [CallerMemberName]string testMethodName = "") 26 | : base(charsToReplace, replacementChars, testMethodName) 27 | { } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Infrastructure/TestCaseBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Routing; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | using Moq; 8 | using RiskFirst.Hateoas.Implementation; 9 | using RiskFirst.Hateoas.Models; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.Linq; 13 | using System.Security.Claims; 14 | using System.Threading.Tasks; 15 | using Microsoft.AspNetCore.Http.Internal; 16 | using Microsoft.Extensions.Primitives; 17 | 18 | namespace RiskFirst.Hateoas.Tests.Infrastructure 19 | { 20 | public class TestCaseBuilder 21 | { 22 | private IRouteMap routeMap; 23 | private LinksOptions options = new LinksOptions(); 24 | private List handlers = new List() { new PassThroughLinksHandler() }; 25 | 26 | private Mock httpContextMock = new Mock(); 27 | private Mock requestMock = new Mock(); 28 | private RouteData routeData = new RouteData(); 29 | private Mock hrefTransformationMock; 30 | private Mock relTransformationMock; 31 | private Mock identityMock; 32 | private Mock principalMock; 33 | private Mock authServiceMock; 34 | 35 | public TestCaseBuilder() 36 | { 37 | httpContextMock.Setup(h => h.Request).Returns(requestMock.Object); 38 | } 39 | 40 | public TestCaseBuilder UseBasicTransformations() 41 | { 42 | var mockTransform = new Mock(); 43 | mockTransform.Setup(x => x.Transform(It.IsAny())).Returns(FormatBasic); 44 | this.hrefTransformationMock = mockTransform; 45 | this.relTransformationMock = mockTransform; 46 | 47 | options.UseHrefTransformation(mockTransform.Object); 48 | options.UseRelTransformation(mockTransform.Object); 49 | return this; 50 | } 51 | 52 | public TestCaseBuilder UseMockHrefTransformation(Action> configureMock) 53 | { 54 | this.hrefTransformationMock = new Mock(); 55 | configureMock?.Invoke(hrefTransformationMock); 56 | options.UseHrefTransformation(hrefTransformationMock.Object); 57 | return this; 58 | } 59 | 60 | public TestCaseBuilder UseLinkBuilderHrefTransformation(Action configureBuilder) 61 | { 62 | var builder = new LinkTransformationBuilder(); 63 | configureBuilder?.Invoke(builder); 64 | options.UseHrefTransformation(builder.Build()); 65 | return this; 66 | } 67 | public TestCaseBuilder UseMockRelTransformation(Action> configureMock) 68 | { 69 | this.relTransformationMock = new Mock(); 70 | configureMock?.Invoke(relTransformationMock); 71 | options.UseRelTransformation(relTransformationMock.Object); 72 | return this; 73 | } 74 | public TestCaseBuilder UseLinkBuilderRelTransformation(Action configureBuilder) 75 | { 76 | var builder = new LinkTransformationBuilder(); 77 | configureBuilder?.Invoke(builder); 78 | options.UseRelTransformation(builder.Build()); 79 | return this; 80 | } 81 | public TestCaseBuilder WithTestRouteMap(Action configureRouteMap = null) 82 | { 83 | var testRouteMap = new TestRouteMap(); 84 | configureRouteMap?.Invoke(testRouteMap); 85 | this.routeMap = testRouteMap; 86 | return this; 87 | } 88 | 89 | public TestCaseBuilder WithMockRouteMap(Action> configureRouteMap = null) 90 | { 91 | var mockRouteMap = new Mock(); 92 | configureRouteMap?.Invoke(mockRouteMap); 93 | this.routeMap = mockRouteMap.Object; 94 | return this; 95 | } 96 | 97 | public TestCaseBuilder WithQueryParams(Dictionary queryParams = null) 98 | { 99 | requestMock.Setup(r => r.Query).Returns(new QueryCollection(queryParams)); 100 | return this; 101 | } 102 | 103 | public TestCaseBuilder WithRequestScheme(string scheme) 104 | { 105 | requestMock.Setup(r => r.Scheme).Returns(scheme); 106 | return this; 107 | } 108 | 109 | public TestCaseBuilder WithMockAuthorizationService(Action> configureAuthService = null) 110 | { 111 | this.authServiceMock = new Mock(); 112 | configureAuthService?.Invoke(authServiceMock); 113 | return this; 114 | } 115 | 116 | public TestCaseBuilder WithHandler() where THandler : ILinksHandler, new() 117 | { 118 | this.handlers.Add(new THandler()); 119 | return this; 120 | } 121 | 122 | public TestCaseBuilder WithIdentity(Action> configureUser) 123 | { 124 | this.identityMock = new Mock(); 125 | configureUser?.Invoke(identityMock); 126 | return this; 127 | } 128 | 129 | public TestCaseBuilder WithPrincipal(Action> configurePrincipal) 130 | { 131 | this.principalMock = new Mock(); 132 | configurePrincipal?.Invoke(principalMock); 133 | return this; 134 | } 135 | 136 | public TestCaseBuilder AddPolicy(Action> configurePolicy) 137 | where TResource : class 138 | { 139 | options.AddPolicy(configurePolicy); 140 | return this; 141 | } 142 | 143 | public TestCaseBuilder AddPolicy(string name, Action> configurePolicy) 144 | where TResource : class 145 | { 146 | options.AddPolicy(name, configurePolicy); 147 | return this; 148 | } 149 | 150 | public ILinksPolicy GetPolicy() 151 | { 152 | return options.GetPolicy(); 153 | } 154 | 155 | public ILinksPolicy GetPolicy(string name) 156 | { 157 | return options.GetPolicy(name); 158 | } 159 | 160 | public TestCaseBuilder DefaultPolicy(Action> configurePolicy) 161 | { 162 | var builder = new LinksPolicyBuilder(); 163 | configurePolicy?.Invoke(builder); 164 | options.DefaultPolicy = builder.Build(); 165 | return this; 166 | } 167 | 168 | public TestCaseBuilder AddRouteValues(Action configureRouteData) 169 | { 170 | configureRouteData?.Invoke(this.routeData); 171 | return this; 172 | } 173 | 174 | 175 | public LinksServiceTestCase BuildLinksServiceTestCase() 176 | { 177 | var authServiceMock = new Mock(); 178 | var serviceLoggerMock = new Mock>(); 179 | var actionContext = CreateActionContext(); 180 | var linkGenerator = new Mock(); 181 | var optionsWrapper = Options.Create(options); 182 | var policyProvider = new DefaultLinksPolicyProvider(optionsWrapper); 183 | var transformationContextFactory = new TestLinkTransformationContextFactory(actionContext, linkGenerator.Object); 184 | var linksHandlerFactory = new TestLinksHandlerContextFactory(routeMap, authServiceMock.Object, actionContext); 185 | var evaluator = new DefaultLinksEvaluator(optionsWrapper, transformationContextFactory); 186 | 187 | var service = new DefaultLinksService(optionsWrapper, serviceLoggerMock.Object, linksHandlerFactory, policyProvider, handlers, routeMap, evaluator); 188 | return new LinksServiceTestCase(service, linksHandlerFactory, authServiceMock, serviceLoggerMock); 189 | } 190 | 191 | public LinksEvaluatorTestCase BuildLinksEvaluatorTestCase() 192 | { 193 | var actionContext = CreateActionContext(); 194 | var linkGenerator = new Mock(); 195 | var optionsWrapper = Options.Create(options); 196 | var transformationContextFactory = new TestLinkTransformationContextFactory(actionContext, linkGenerator.Object); 197 | var evaluator = new DefaultLinksEvaluator(optionsWrapper, transformationContextFactory); 198 | 199 | return new LinksEvaluatorTestCase(evaluator, this.hrefTransformationMock, this.relTransformationMock); 200 | } 201 | 202 | public LinksAuthorizationTestCase BuildAuthorizationServiceTestCase() 203 | { 204 | var service = new DefaultLinkAuthorizationService(authServiceMock.Object); 205 | 206 | var claimsPrincipalMock = this.principalMock ?? new Mock(); 207 | claimsPrincipalMock.CallBase = true; 208 | claimsPrincipalMock.Object.AddIdentity(this.identityMock?.Object ?? new Mock().Object); 209 | 210 | return new LinksAuthorizationTestCase(service, claimsPrincipalMock.Object, this.routeMap, this.authServiceMock); 211 | } 212 | 213 | private ActionContext CreateActionContext() 214 | { 215 | var actionContext = new ActionContext(httpContextMock.Object, routeData, new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()); 216 | return actionContext; 217 | } 218 | 219 | private static string FormatBasic(LinkTransformationContext ctx) 220 | { 221 | var parameters = ctx.LinkSpec.RouteValues?.Select(x => $"{x.Key}={x.Value}"); 222 | 223 | return ctx.LinkSpec.RouteName + (parameters != null && parameters.Any() ? String.Concat("?", String.Join("&", parameters)) : ""); 224 | } 225 | 226 | private class TestLinkTransformationContextFactory : ILinkTransformationContextFactory 227 | { 228 | private readonly ActionContext actionContext; 229 | private readonly LinkGenerator generator; 230 | 231 | public TestLinkTransformationContextFactory(ActionContext actionContext, LinkGenerator generator) 232 | { 233 | this.actionContext = actionContext; 234 | this.generator = generator; 235 | } 236 | 237 | public LinkTransformationContext CreateContext(ILinkSpec spec) 238 | { 239 | return new LinkTransformationContext(spec, actionContext, generator); 240 | } 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Infrastructure/TestCases.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Logging; 4 | using Moq; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Net.Http; 9 | using System.Security.Claims; 10 | using System.Threading.Tasks; 11 | 12 | namespace RiskFirst.Hateoas.Tests.Infrastructure 13 | { 14 | public class LinksServiceTestCase 15 | { 16 | public ILinksService UnderTest { get; } 17 | public TestLinksHandlerContextFactory LinksHandlerContextFactory { get; } 18 | public Mock AuthServiceMock { get; } 19 | public Mock> ServiceLoggerMock { get; } 20 | public LinksServiceTestCase(ILinksService underTest, 21 | TestLinksHandlerContextFactory linksHandlerContextFactory, 22 | Mock authServiceMock, 23 | Mock> serviceLoggerMock) 24 | { 25 | this.UnderTest = underTest; 26 | this.LinksHandlerContextFactory = linksHandlerContextFactory; 27 | this.AuthServiceMock = authServiceMock; 28 | this.ServiceLoggerMock = serviceLoggerMock; 29 | } 30 | } 31 | 32 | public class LinksEvaluatorTestCase 33 | { 34 | public ILinksEvaluator UnderTest { get; } 35 | public Mock HrefTransformMock { get; } 36 | public Mock RelTransformMock { get; } 37 | public LinksEvaluatorTestCase(ILinksEvaluator underTest, 38 | Mock hrefTransformMock, Mock relTransformMock) 39 | { 40 | this.UnderTest = underTest; 41 | this.HrefTransformMock = hrefTransformMock; 42 | this.RelTransformMock = relTransformMock; 43 | } 44 | } 45 | 46 | public class LinksAuthorizationTestCase 47 | { 48 | public ILinkAuthorizationService UnderTest { get; } 49 | 50 | public ClaimsPrincipal User { get; } 51 | 52 | public Mock AuthorizationServiceMock { get; } 53 | 54 | private IRouteMap routeMap; 55 | 56 | public LinksAuthorizationTestCase(ILinkAuthorizationService underTest, ClaimsPrincipal user, IRouteMap routeMap, Mock authServiceMock) 57 | { 58 | this.UnderTest = underTest; 59 | this.User = user; 60 | this.routeMap = routeMap; 61 | this.AuthorizationServiceMock = authServiceMock; 62 | } 63 | 64 | public LinkAuthorizationContext CreateContext(TResource resource, bool requiresRouteAuthorization, IEnumerable requirements = null, IEnumerable policies = null) 65 | { 66 | var route = this.routeMap.GetRoute("AuthTestRoute"); 67 | return new LinkAuthorizationContext( 68 | requiresRouteAuthorization, 69 | requirements, 70 | policies, 71 | route, 72 | new Microsoft.AspNetCore.Routing.RouteValueDictionary(), 73 | resource, 74 | this.User); 75 | } 76 | 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Infrastructure/TestLinksHandlerContextFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.Extensions.Logging; 3 | using Moq; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | 9 | namespace RiskFirst.Hateoas.Tests.Infrastructure 10 | { 11 | public class TestLinksHandlerContextFactory : ILinksHandlerContextFactory 12 | { 13 | private readonly IRouteMap routeMap; 14 | private readonly ILinkAuthorizationService authService; 15 | private readonly ActionContext actionContext; 16 | 17 | public TestLinksHandlerContextFactory(IRouteMap routeMap, ILinkAuthorizationService authService, ActionContext actionContext) 18 | { 19 | this.routeMap = routeMap; 20 | this.authService = authService; 21 | this.actionContext = actionContext; 22 | } 23 | 24 | public Mock LinksHandlerContextMock { get; private set; } 25 | public Mock LoggerMock { get; private set; } 26 | public LinksHandlerContext CreateContext(IEnumerable requirements, object resource) 27 | { 28 | LoggerMock = new Mock>(); 29 | LinksHandlerContextMock = new Mock(requirements, routeMap, authService, LoggerMock.Object, actionContext, resource); 30 | LinksHandlerContextMock.CallBase = true; 31 | return (LinksHandlerContext)LinksHandlerContextMock.Object; 32 | } 33 | 34 | public Mock GetLinksHandlerContextMock() 35 | { 36 | return (Mock)this.LinksHandlerContextMock; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Infrastructure/TestRouteMap.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Tests.Infrastructure 7 | { 8 | public class TestRouteMap : IRouteMap 9 | { 10 | private IDictionary routes = new Dictionary(); 11 | private string currentRoute; 12 | 13 | public TestRouteMap AddRoute(RouteInfo routeInfo) 14 | { 15 | this.routes.Add(routeInfo.RouteName, routeInfo); 16 | return this; 17 | } 18 | 19 | public TestRouteMap SetCurrentRoute(string name) 20 | { 21 | if (routes.ContainsKey(name)) 22 | this.currentRoute = name; 23 | return this; 24 | } 25 | 26 | public RouteInfo GetCurrentRoute() 27 | { 28 | return routes[this.currentRoute]; 29 | } 30 | 31 | public RouteInfo GetRoute(string name) 32 | { 33 | return routes.ContainsKey(name)? routes[name] : null; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/JsonSerializationTests.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using RiskFirst.Hateoas.Models; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using Xunit; 6 | 7 | namespace RiskFirst.Hateoas.Tests 8 | { 9 | public class JsonSerializationTests 10 | { 11 | [Fact] 12 | public void WhenLinksAreAvailable_ShouldSerializeThemProperly() 13 | { 14 | // Arrange 15 | var testLinks = new List 16 | { 17 | new Link { Name = "self", Href = "https://myamazingapp.com/12", Method = "GET", Rel = "self-rel" }, 18 | new Link { Name = "create", Href = "https://myamazingapp.com/create", Method = "POST", Rel = "create-rel" }, 19 | new Link { Name = "delete", Href = "https://myamazingapp.com/delete", Method = "DELETE", Rel = "delete-rel" } 20 | }; 21 | 22 | var container = new TestLinkContainer(testLinks); 23 | 24 | // Act 25 | var result = JsonConvert.SerializeObject(container); 26 | 27 | // Assert 28 | var deserialized = JsonConvert.DeserializeObject(result); 29 | 30 | Assert.True(deserialized.Links.Count > 0); 31 | 32 | var linksAreCorrect = deserialized.Links.All(l => testLinks 33 | .Any(tl => tl.Href == l.Href && 34 | tl.Method == l.Method && 35 | tl.Name == l.Name && 36 | tl.Rel == l.Rel)); 37 | 38 | Assert.True(linksAreCorrect); 39 | } 40 | 41 | [Fact] 42 | public void WhenNoLinksAreAvailable_NoLinksShouldBeSerialized() 43 | { 44 | // Arrange 45 | var testLinks = Enumerable.Empty(); 46 | 47 | var container = new TestLinkContainer(testLinks); 48 | 49 | // Act 50 | var result = JsonConvert.SerializeObject(container); 51 | 52 | // Assert 53 | var deserialized = JsonConvert.DeserializeObject(result); 54 | 55 | Assert.Equal(0, deserialized.Links.Count); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/LinksOptionsTests.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Tests.Infrastructure; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using Xunit; 7 | 8 | namespace RiskFirst.Hateoas.Tests 9 | { 10 | public class LinksOptionsTests 11 | { 12 | private readonly LinksOptions linksOptions; 13 | 14 | public LinksOptionsTests() 15 | { 16 | linksOptions = new LinksOptions(); 17 | } 18 | 19 | [AutonamedFact] 20 | public void WhenAddingABuiltPolicy_PolicyShouldBeMappedCorrectly() 21 | { 22 | // Arrange 23 | var policyToAdd = BuildTestPolicy(); 24 | 25 | // Act 26 | linksOptions.AddPolicy(policyToAdd); 27 | 28 | // Assert 29 | var addedPolicy = linksOptions.GetPolicy(); 30 | 31 | AssertPoliciesMatch(policyToAdd, addedPolicy); 32 | } 33 | 34 | [AutonamedFact] 35 | public void WhenAddingANamedBuiltPolicy_PolicyShouldBeMappedCorrectly() 36 | { 37 | // Arrange 38 | var policyName = "TestName"; 39 | var policyToAdd = BuildTestPolicy(); 40 | 41 | // Act 42 | linksOptions.AddPolicy(policyName, policyToAdd); 43 | 44 | // Assert 45 | var addedPolicy = linksOptions.GetPolicy(policyName); 46 | 47 | AssertPoliciesMatch(policyToAdd, addedPolicy); 48 | } 49 | 50 | [AutonamedFact] 51 | public void WhenAddingAPolicyWithNullName_ExpectArgumentException() 52 | { 53 | // Arrange 54 | var policy = BuildTestPolicy(); 55 | 56 | // Act, Assert 57 | Assert.Throws(() => 58 | { 59 | linksOptions.AddPolicy(null, policy); 60 | }); 61 | } 62 | 63 | [AutonamedFact] 64 | public void WhenAddingANullPolicy_ExpectArgumentNullException() 65 | { 66 | Assert.Throws(() => 67 | { 68 | linksOptions.AddPolicy((ILinksPolicy)null); 69 | }); 70 | } 71 | 72 | [AutonamedFact] 73 | public void WhenAddingNullNamedPolicy_ExpectArgumentNullException() 74 | { 75 | Assert.Throws(() => 76 | { 77 | linksOptions.AddPolicy("SomeName", (ILinksPolicy)null); 78 | }); 79 | } 80 | 81 | private static void AssertPoliciesMatch(ILinksPolicy policyToAdd, ILinksPolicy addedPolicy) 82 | { 83 | Assert.NotNull(addedPolicy); 84 | Assert.True(policyToAdd.Requirements.Count == addedPolicy.Requirements.Count); 85 | 86 | var expectedRequirements = policyToAdd.Requirements; 87 | 88 | Assert.True(policyToAdd.Requirements.All(r => expectedRequirements.Any(er => er.GetType() == r.GetType()))); 89 | } 90 | 91 | private static ILinksPolicy BuildTestPolicy() => 92 | new LinksPolicyBuilder() 93 | .RequireSelfLink() 94 | .RequireRoutedLink("some-link", "ArbitraryRouteName") 95 | .Build(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/Polyfills/DefaultAssemblyLoaderTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Reflection; 3 | using RiskFirst.Hateoas.Polyfills; 4 | using RiskFirst.Hateoas.Tests.Infrastructure; 5 | using Xunit; 6 | 7 | namespace RiskFirst.Hateoas.Tests.Polyfills 8 | { 9 | [Trait("Category", "Polyfills")] 10 | public class DefaultAssemblyLoaderTests 11 | { 12 | [AutonamedFact] 13 | public void WhenGetAssemblies_ShouldReturnAssemblies() 14 | { 15 | var loader = new DefaultAssemblyLoader(); 16 | var assemblies = loader.GetAssemblies(); 17 | 18 | assemblies.First(a => a.FullName == GetType().GetTypeInfo().Assembly.FullName); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/RiskFirst.Hateoas.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Library 5 | 6 | 7 | 8 | net7.0 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/TestLinkContainers.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | using System.Collections.Generic; 3 | 4 | namespace RiskFirst.Hateoas.Tests 5 | { 6 | public class DerivedLinkContainer : TestLinkContainer { } 7 | 8 | [Links()] 9 | public class EmptyOverrideTestLinkContainer : LinkContainer 10 | { 11 | public EmptyOverrideTestLinkContainer() 12 | { 13 | } 14 | 15 | public int Id { get; set; } 16 | } 17 | 18 | [Links(Policy = "OverridePolicy")] 19 | public class OverrideTestLinkContainer : LinkContainer 20 | { 21 | public OverrideTestLinkContainer() 22 | { 23 | } 24 | 25 | public int Id { get; set; } 26 | } 27 | 28 | public class TestLinkContainer : LinkContainer 29 | { 30 | public TestLinkContainer() 31 | { 32 | } 33 | 34 | public TestLinkContainer(IEnumerable links) 35 | { 36 | foreach (var link in links) 37 | { 38 | Links.Add(link); 39 | } 40 | } 41 | 42 | public int Id { get; set; } 43 | } 44 | 45 | public class TestPagedLinkContainer : PagedItemsLinkContainer 46 | { 47 | } 48 | 49 | public class UnrelatedLinkContainer : LinkContainer { } 50 | } -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/TestRequirement.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace RiskFirst.Hateoas.Tests 7 | { 8 | public class TestRequirement : ILinksRequirement/**/ 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/TestRequirementHandler.cs: -------------------------------------------------------------------------------- 1 | using Moq; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | 8 | namespace RiskFirst.Hateoas.Tests 9 | { 10 | public class TestRequirementHandler : LinksHandler> 11 | where TResource : class 12 | { 13 | public TestRequirementHandler() 14 | { 15 | } 16 | protected override Task HandleRequirementAsync(LinksHandlerContext context, TestRequirement requirement) 17 | { 18 | var route = context.RouteMap.GetRoute("TestRoute"); 19 | context.Links.Add(new LinkSpec("testLink",route)); 20 | context.Handled(requirement); 21 | return Task.CompletedTask; 22 | } 23 | } 24 | 25 | public class ExceptionRequirementHandler : LinksHandler> 26 | where TResource : class 27 | { 28 | public ExceptionRequirementHandler() 29 | { 30 | } 31 | protected override Task HandleRequirementAsync(LinksHandlerContext context, TestRequirement requirement) 32 | { 33 | throw new Exception("Test Exception"); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/RiskFirst.Hateoas.Tests/XmlSerizalizationTests.cs: -------------------------------------------------------------------------------- 1 | using RiskFirst.Hateoas.Models; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Xml.Serialization; 6 | using Xunit; 7 | 8 | namespace RiskFirst.Hateoas.Tests 9 | { 10 | public class XmlSerizalizationTests 11 | { 12 | [Fact] 13 | public void WhenLinksAreAvailable_ShouldSerializeThemProperly() 14 | { 15 | // Arrange 16 | var testLinks = new List 17 | { 18 | new Link { Name = "self", Href = "https://myamazingapp.com/12", Method = "GET", Rel = "self-rel" }, 19 | new Link { Name = "create", Href = "https://myamazingapp.com/create", Method = "POST", Rel = "create-rel" }, 20 | new Link { Name = "delete", Href = "https://myamazingapp.com/delete", Method = "DELETE", Rel = "delete-rel" } 21 | }; 22 | 23 | var container = new TestLinkContainer(testLinks); 24 | 25 | // Act 26 | var xml = SerializeToXml(container); 27 | 28 | // Assert 29 | var result = DeserializeXml(xml); 30 | 31 | Assert.Equal(result.Links.Count, testLinks.Count); 32 | 33 | var linksAreCorrect = result.Links.All(l => testLinks 34 | .Any(tl => tl.Href == l.Href && 35 | tl.Method == l.Method && 36 | // We purposefully skip comparing by Rel because we expect it to be lost 37 | // during the serialization/deserialization (and that's ok) 38 | tl.Name == l.Name)); 39 | 40 | Assert.True(linksAreCorrect); 41 | } 42 | 43 | [Fact] 44 | public void WhenNoLinksAreAvailable_NoLinksShouldBeSerialized() 45 | { 46 | // Arrange 47 | var testLinks = Enumerable.Empty(); 48 | 49 | var container = new TestLinkContainer(testLinks); 50 | 51 | // Act 52 | var xml = SerializeToXml(container); 53 | 54 | // Assert 55 | var result = DeserializeXml(xml); 56 | 57 | Assert.Equal(0, result.Links.Count); 58 | } 59 | 60 | private static T DeserializeXml(string xml) 61 | { 62 | using (var reader = new StringReader(xml)) 63 | { 64 | var serializer = new XmlSerializer(typeof(T)); 65 | return (T)serializer.Deserialize(reader); 66 | } 67 | } 68 | 69 | private static string SerializeToXml(object obj) 70 | { 71 | var serializer = new XmlSerializer(obj.GetType()); 72 | 73 | using (var writer = new StringWriter()) 74 | { 75 | serializer.Serialize(writer, obj); 76 | return writer.ToString(); 77 | } 78 | } 79 | } 80 | } --------------------------------------------------------------------------------