5
\$\begingroup\$

GitHub and NuGet

Allows to invoke public/private REST API just by defining an interface. 200 lines of C# in total.

Demo

Sample API is publicly available at http://jsonplaceholder.typicode.com:

public interface ITypicode : IDisposable
{
    [Description("GET posts")]
    Task<BlogPost[]> GetAsync();

    [Description("GET posts/{0}")]
    Task<BlogPost> GetAsync(int id);

    [Description("PUT posts/{0} {1}")]
    Task<BlogPost> PutAsync(int id, BlogPost data);
}

public class BlogPost
{
    public int UserId { get; set; }
    public int Id { get; set; }
    public string Title { get; set; }
    public string Body { get; set; }
}

Now we could test proxy generator:

[TestMethod]
public async Task Call_REST_API()
{
    using (var proxy = Proxy.Create<ITypicode>("http://jsonplaceholder.typicode.com"))
    {
        var posts = await proxy.GetAsync();
        Assert.AreEqual(100, posts.Length);

        var post = await proxy.GetAsync(1);
        Assert.AreEqual(1, post.Id);

        post.Title = "XYZ";
        post = await proxy.PutAsync(1, post);
        Assert.AreEqual("XYZ", post.Title);
    }
}

Optional authenticate parameter supports custom authentication schemas using ad-hoc HttpClient extension method to setup HttpClient.DefaultRequestHeaders in a way like this:

Proxy.Create<ITypicode>(url, (HttpClient client) => client.AuthenticateAsync(...))

Library

Library code contains a unified proxy generator based on Castle.Core:

public static class Proxy
{
    public static T Create<T>(object target) where T : class =>
        Create<T>(call => Dynamic.InvokeMember(
            target, call.Name, call.Args.ToArray()));

    public static T Create<T>(string apiUrl, Func<HttpClient, Task> authenticate = null) where T : class, IDisposable =>
        Create<T>(new Uri(apiUrl, UriKind.Absolute), authenticate);

    public static T Create<T>(Uri apiUrl, Func<HttpClient, Task> authenticate = null) where T : class, IDisposable =>
        RestProxy.Create<T>(apiUrl, authenticate);

    public static T Create<T>(Func<ProxyCall, object> target) where T : class
    {
        var proxyGenerator = new ProxyGenerator();
        return proxyGenerator.CreateInterfaceProxyWithoutTarget<T>(
            ProxyGenerationOptions.Default,
            new Interceptor(target));
    }

    class Interceptor : IInterceptor
    {
        public Interceptor(Func<ProxyCall, object> target) => Target = target;
        Func<ProxyCall, object> Target { get; }
        public void Intercept(IInvocation invocation) =>
            invocation.ReturnValue = Target(
                new ProxyCall(invocation.Method.Name, invocation.Arguments)
                    .Returns(invocation.Method.ReturnType)
                    .Define(invocation.Method.GetCustomAttributes()));
    }
}

Where helpers are:

public class ProxyCall
{
    public ProxyCall(string name, IEnumerable<object> args)
        : this(name, args.ToArray())
    {
    }

    public ProxyCall(string name, params object[] args)
    {
        Name = name;
        Args = args;
    }

    public string Name { get; }
    public IReadOnlyList<object> Args { get; }

    public CallResult Result { get; private set; } = CallResult.None;
    public ProxyCall Returns<T>() => Returns(typeof(T));
    public ProxyCall ReturnsAsync<T>() => Returns(typeof(Task<T>));
    public ProxyCall Returns(Type type) => With(result: new CallResult(type));

    public CallMethod Method { get; private set; } = CallMethod.Undefined;
    public ProxyCall Define(string description) =>
        Define(new DescriptionAttribute(description));
    public ProxyCall Define<TAttribute>() where TAttribute : Attribute, new() =>
        Define(new TAttribute());
    public ProxyCall Define(IEnumerable<Attribute> attributes) =>
        Define(attributes.ToArray());
    public ProxyCall Define(params Attribute[] attributes) => 
        With(method: new CallMethod(attributes.Concat(Method)));

    ProxyCall With(CallResult result = null, CallMethod method = null) => 
        new ProxyCall(Name, Args) 
        { 
            Result = result ?? Result,
            Method = method ?? Method
        };

    public bool IsDispose => 
        Name == nameof(IDisposable.Dispose) &&
        Result.Void &&
        !Args.Any();
}

And:

public class CallResult 
{
    public static readonly CallResult None = new CallResult(typeof(void));

    internal CallResult(Type raw) => Raw = raw;
    public Type Raw { get; }
    public bool Sync => !Async;
    public bool Async => typeof(Task).IsAssignableFrom(Raw);
    public bool Void => Raw == typeof(void) || Raw == typeof(Task);
    public Type Type => Async 
        ? (Void ? typeof(void) : Raw.GetGenericArguments()[0])
        : Raw;
}

And:

public class CallMethod : ReadOnlyCollection<Attribute>
{
    public static readonly CallMethod Undefined = new CallMethod();

    internal CallMethod(IEnumerable<Attribute> attributes) 
        : this(attributes.ToArray())
    {
    }

    internal CallMethod(params Attribute[] attributes)
        : base(attributes)
    {
    }

    public bool Contains<TAttribute>() where TAttribute : Attribute =>
        Select<TAttribute>().Any();
    public T Peek<TAttribute, T>(Func<TAttribute, T> selector) where TAttribute : Attribute =>
        Select(selector).FirstOrDefault();
    public IEnumerable<Attribute> Select<TAttribute>() where TAttribute : Attribute =>
        Select((TAttribute a) => a);
    public IEnumerable<T> Select<TAttribute, T>(Func<TAttribute, T> selector) where TAttribute : Attribute =>
        Items.OfType<TAttribute>().Select(selector);

    public string Description => Peek((DescriptionAttribute a) => a.Description);
}

The following class provides all necessary support for REST API calls:

static class RestProxy
{
    public static T Create<T>(Uri apiUrl, Func<HttpClient, Task> authenticate = null) 
        where T : class, IDisposable
    {
        var client = new HttpClient() { BaseAddress = apiUrl };
        return Proxy.Create<T>(Execute);            
        object Execute(ProxyCall call)
        {
            if(call.IsDispose)
            {
                client.Dispose();
                return null;
            }

            return call.Result.Void ? Send() : SendAndReceive();
            object Send() => client.SendAsync(call, authenticate);
            object SendAndReceive() =>
                typeof(RestProxy)
                    .GetMethod(nameof(SendAndReceiveAsync), BindingFlags.Static | BindingFlags.NonPublic)
                    .MakeGenericMethod(call.Result.Type)
                    .Invoke(null, new object[] { client, call, authenticate });
        }
    }        

    static async Task<T> SendAndReceiveAsync<T>(
        HttpClient client, ProxyCall call, Func<HttpClient, Task> authenticate)
    {
        var response = await client.SendAsync(call, authenticate);
        var json = await response.Content.ReadAsStringAsync();
        return JsonConvert.DeserializeObject<T>(json);
    }

    static async Task<HttpResponseMessage> SendAsync(
        this HttpClient client, ProxyCall call, Func<HttpClient, Task> authenticate = null)
    {
        var description = call.Method.Description
            .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

        var request = new HttpRequestMessage(
            new HttpMethod(description[0]),
            new Uri(
                string.Format(description[1], call.Args.ToArray()),
                UriKind.Relative))
            {
                Content = description.Length < 3 ||
                    !int.TryParse(description[2].Trim("{}".ToCharArray()), out var index)
                    ? null
                    : new StringContent(
                        JsonConvert.SerializeObject(call.Args[index]),
                        Encoding.UTF8,
                        "application/json")
            };

        var response = await client.SendAsync(request);
        if (response.StatusCode == HttpStatusCode.Unauthorized)
            if(authenticate == null)
                throw new AuthenticationException();
            else
            {
                await authenticate(client);
                return await SendAsync(client, call);
            }

        response.EnsureSuccessStatusCode();
        return response;            
    }
}

P.S. Let me know please if something is missing here which could prevent it from being really useful :)

\$\endgroup\$
0

1 Answer 1

6
\$\begingroup\$

You require the interface to be IDisposable which is a bit of a shame. You're only doing this so that you can dispose of the HttpClient, but as it says in the Microsoft docs:

HttpClient is intended to be instantiated once and re-used throughout the life of an application. Instantiating an HttpClient class for every request will exhaust the number of sockets available under heavy loads.

Unfortunately it's a difficult class to use correctly so I recomend swapping it out for something that's already got many of the common mistakes taken care of, like the Easy.Common RestClient. This will make sure the clients are cached and as a bonus will not require the interface to be IDisposable.

It will bring a dependency but since you're already depending on some dependency injection package this may not be an issue.


In the RestProxy you have many awaits but you do not ConfigureAwait(false). You should really use that in library code unless you're only targeting .NET Core, it will perform slightly better and may prevent deadlocks for callers who use the async methods incorrectly.

When you send messages and recieve responses you turn the objects into strings but throw the strings away, this is wasteful and creates work for the Garbage Collector. Netwonsoft supports serializing and deserialzing to and from streams. You should stream the data out in. Follow this guide or ask if you are unsure how.

You do not dispose of your HttpRequestMessages and HttpResponseMessages or your StringContents. StringContent would be disposed of by the HttpRequestMessages but you should really be disposing of every IDisposable you create (except the HttpClient, as mentioned above!).

You do not support cancellation tokens it looks like. You should consider the case when an interface looks like:

public interface ITypicode : IDisposable
{
    [Description("GET posts")]
    Task<BlogPost[]> GetAsync(CancellationToken cancellationToken);

    [Description("GET posts/{0}")]
    Task<BlogPost> GetAsync(int id, CancellationToken cancellationToken);

    [Description("PUT posts/{0} {1}")]
    Task<BlogPost> PutAsync(int id, BlogPost data, CancellationToken cancellationToken);
}

as most async interfaces should look (It's a common use case to want to cancel an async request after you've already sent it).

That's all the improvements I can think of so far, but I don't want you think I'm being negative - it's actually a really nice package and a great idea well executed, very impressive.

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Thanks for this great answer - I hope to see more from you in future! \$\endgroup\$ Commented Dec 3, 2019 at 17:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.