Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new PathPrefix attribute #1685

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Expand Up @@ -175,6 +175,29 @@ Search("admin/products");
>>> "/search/admin/products"
```

#### Path prefix

When a group of methods share the same relative URL path prefix, you can use the PathPrefix annotation. When given, the
path prefix will be concatenated after the base URL and before the relative path as specified in the method's annotation.

```csharp
[PathPrefix("/resources")]
public interface IResourcesService
{
[Get("")]
Task<List<Resource>> ReadAll();

[Get("/{key}")]
Task<Resource> ReadOne(TKey key);
}

Get("");
>>> "/resources"

Get("/123");
>>> "/resources/123"
```

### Dynamic Querystring Parameters

If you specify an `object` as a query parameter, all public properties which are not null are used as query parameters.
Expand Down Expand Up @@ -1050,6 +1073,32 @@ public interface IDerivedServiceB : IBaseService

In this example, the `IDerivedServiceA` interface will expose both the `GetResource` and `DeleteResource` APIs, while `IDerivedServiceB` will expose `GetResource` and `AddResource`.

#### Path prefix

The principle of interface inheritance can be used together with the PathPrefix annotation. Like this:

```csharp
[PathPrefix("/resources")]
public interface IBaseResourcesService
{
[Get("")]
Task<List<Resource>> ReadAll();
}

public interface ISpecificResourcesService : IBaseResourcesService
{
[Get("/{key}")]
Task<Resource> GetSomethingSpecific(TKey key);
}

Get("");
>>> "/resources"

Get("/123");
>>> "/resources/123"
```


#### Headers inheritance

When using inheritance, existing header attributes will be passed along as well, and the inner-most ones will have precedence:
Expand Down
26 changes: 26 additions & 0 deletions Refit.Tests/IPathPrefix.cs
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Refit; // InterfaceStubGenerator looks for this
using Refit.Tests.SeparateNamespaceWithModel;

using static System.Math; // This is here to verify https://github.com/reactiveui/refit/issues/283

namespace Refit.Tests
{
[Headers("User-Agent: Refit Integration Tests")]
[PathPrefix("/ping")]
public interface IPathPrefix
{
[Get("/get?result=Ping")]
Task<string> Ping();
}

[Headers("User-Agent: Refit Integration Tests")]
public interface IInheritingPathPrefix : IPathPrefix
{
[Get("/get?result=Pang")]
Task<string> Pang();
}

}
43 changes: 43 additions & 0 deletions Refit.Tests/ReflectionHelpersTests.cs
@@ -0,0 +1,43 @@
using System;
using Xunit;
using Refit;

namespace Refit.Tests
{
public class ReflectionHelpersTests
{
[Fact]
public void NullTargetInterfaceThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => ReflectionHelpers.GetPathPrefixFor(null));
}

[Fact]
public void TargetInterfaceHasPathPrefixAttributeReturnsCorrectPathPrefix()
{
var pathPrefix = ReflectionHelpers.GetPathPrefixFor(typeof(IInterfaceWithPathPrefix));
Assert.Equal("/pathPrefix", pathPrefix);
}

[Fact]
public void InheritedInterfaceHasPathPrefixAttributeReturnsCorrectPathPrefix()
{
var pathPrefix = ReflectionHelpers.GetPathPrefixFor(typeof(IInterfaceInheritingPathPrefix));
Assert.Equal("/pathPrefix", pathPrefix);
}

[Fact]
public void NoPathPrefixAttributeReturnsEmptyString()
{
var pathPrefix = ReflectionHelpers.GetPathPrefixFor(typeof(IInterfaceWithoutPathPrefix));
Assert.Equal(string.Empty, pathPrefix);
}

[PathPrefix("/pathPrefix")]
public interface IInterfaceWithPathPrefix { }

public interface IInterfaceInheritingPathPrefix : IInterfaceWithPathPrefix { }

public interface IInterfaceWithoutPathPrefix { }
}
}
34 changes: 34 additions & 0 deletions Refit.Tests/RestService.cs
Expand Up @@ -1993,6 +1993,40 @@ public async Task InheritedInterfaceWithoutRefitInBaseMethodsTest()
);
}

[Fact]
public async Task PathPrefixAttributeTest()
{
var mockHttp = new MockHttpMessageHandler();

var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };

var fixture = RestService.For<IPathPrefix>("https://httpbin.org", settings);

mockHttp
.Expect(HttpMethod.Get, "https://httpbin.org/ping/get")
.Respond("application/json", nameof(IPathPrefix.Ping));
var resp = await fixture.Ping();
Assert.Equal(nameof(IPathPrefix.Ping), resp);
mockHttp.VerifyNoOutstandingExpectation();
}

[Fact]
public async Task PathPrefixAttributeInheritanceTest()
{
var mockHttp = new MockHttpMessageHandler();

var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };

var fixture = RestService.For<IInheritingPathPrefix>("https://httpbin.org", settings);

mockHttp
.Expect(HttpMethod.Get, "https://httpbin.org/ping/get")
.Respond("application/json", nameof(IInheritingPathPrefix.Pang));
var resp = await fixture.Pang();
Assert.Equal(nameof(IInheritingPathPrefix.Pang), resp);
mockHttp.VerifyNoOutstandingExpectation();
}

[Fact]
public async Task DictionaryDynamicQueryparametersTest()
{
Expand Down
17 changes: 17 additions & 0 deletions Refit/Attributes.cs
Expand Up @@ -2,6 +2,23 @@

namespace Refit
{
/// <summary>
/// Apply the given path prefix to all requests.
/// </summary>
/// <remarks>
/// When set, this will be used between the base URL and the endpoint's specific path.
/// </remarks>
[AttributeUsage(AttributeTargets.Interface)]
public class PathPrefixAttribute : Attribute
{
public PathPrefixAttribute(string pathPrefix)
{
PathPrefix = pathPrefix;
}

public string PathPrefix { get; }
}

public abstract class HttpMethodAttribute : Attribute
{
public HttpMethodAttribute(string path)
Expand Down
54 changes: 54 additions & 0 deletions Refit/ReflectionHelpers.cs
@@ -0,0 +1,54 @@
using System.Reflection;

namespace Refit
{
/// <summary>
/// Provides utility methods for reflection-based operations.
/// </summary>
public static class ReflectionHelpers
{
/// <summary>
/// Retrieves the path prefix defined by a <see cref="PathPrefixAttribute"/> on a specified interface or its inherited interfaces.
/// </summary>
/// <param name="targetInterface">The interface type from which to retrieve the path prefix.</param>
/// <returns>
/// The path prefix if a <see cref="PathPrefixAttribute"/> is found; otherwise, an empty string.
/// </returns>
/// <remarks>
/// This method first checks the specified interface for the <see cref="PathPrefixAttribute"/>. If not found,
/// it then checks each interface inherited by the target interface. If no attribute is found after all checks,
/// the method returns an empty string.
/// </remarks>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="targetInterface"/> is null.
/// </exception>
public static string GetPathPrefixFor(Type targetInterface)
{
// Manual null check for compatibility with older .NET versions
if (targetInterface == null)
{
throw new ArgumentNullException(nameof(targetInterface));
}

// Check if the attribute is applied to the type T itself
var attribute = targetInterface.GetCustomAttribute<PathPrefixAttribute>();
if (attribute != null)
{
return attribute.PathPrefix;
}

// If the attribute is not found on T, check its interfaces
foreach (var interfaceType in targetInterface.GetInterfaces())
{
attribute = interfaceType.GetCustomAttribute<PathPrefixAttribute>();
if (attribute != null)
{
return attribute.PathPrefix;
}
}

// If the attribute is still not found, return empty string
return string.Empty;
}
}
}
2 changes: 1 addition & 1 deletion Refit/RestMethodInfo.cs
Expand Up @@ -68,7 +68,7 @@ internal class RestMethodInfoInternal
var hma = methodInfo.GetCustomAttributes(true).OfType<HttpMethodAttribute>().First();

HttpMethod = hma.Method;
RelativePath = hma.Path;
RelativePath = ReflectionHelpers.GetPathPrefixFor(targetInterface) + hma.Path;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is concatenation enough? I think it should also add a / separator if necessary.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think Refit should try to be intelligent by adding slashes. I think it's the responsibility of the implementing user to annotate correctly. There could be situations where you actually want to concatenate instead of an automatically added slash. Don't you think?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could be situations where you actually want to concatenate instead of an automatically added slash.

To be honest, I can't really think of any situation where I would want to concatenate without the slash. Unless you want to have a prefix like "i" and suffixes like "tem" and "nventory", but that seems like a bad idea to me...

I think Refit should "do the right thing" to make things easier for the developer. But I guess the right answer is to check what Refit does elsewhere, and do the same...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomaslevesque Agreed, on both points. I will take a look into that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/reactiveui/refit/blob/main/Refit/RequestBuilderImplementation.cs#L639-L640

It seems like Refit is also just concatenating the basepath and the relative path (unless the basepath is empty). I agree that this could be more foolproof, however, it seems like a separate issue to me.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Let's leave it like this for now.


IsMultipart = methodInfo.GetCustomAttributes(true).OfType<MultipartAttribute>().Any();

Expand Down