Skip to content

Instantly share code, notes, and snippets.

@steff-mueller
Forked from vansha/RestServiceExtensions.cs
Created August 26, 2012 21:59
Show Gist options
  • Save steff-mueller/3483839 to your computer and use it in GitHub Desktop.
Save steff-mueller/3483839 to your computer and use it in GitHub Desktop.
ServiceStack RestService extension allowing to call REST services in a generic way.
namespace ServiceStack.Service
{
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using ServiceStack.Service;
using ServiceStack.ServiceClient.Web;
using ServiceStack.ServiceHost;
using ServiceStack.Text;
/// <summary>
/// Marker interface for the request DTO.
/// </summary>
/// <typeparam name="TResponse">
/// The type of the corresponding response class. It should follow this naming convention:
/// {RequestClass}<c>Response</c>
/// </typeparam>
/// <remarks>
/// Just a trick to automatically determine response type in the <see cref="RestServiceExtension"/> to help
/// client developer via intellisense.
/// </remarks>
public interface IRequest<TResponse>
{
}
public static class RestServiceExtension
{
private static readonly ConcurrentDictionary<Type, List<RestRoute>> routesCache =
new ConcurrentDictionary<Type, List<RestRoute>>();
public static TResponse Send<TResponse>(this IRestClient service, IRequest<TResponse> request)
{
var requestType = request.GetType();
List<RestRoute> requestRoutes = routesCache.GetOrAdd(requestType, GetRoutesForType);
if (!requestRoutes.Any())
{
throw new InvalidOperationException("There is no rest routes mapped for '{0}' type.".Fmt(requestType));
}
var routesApplied =
requestRoutes.Select(route => new { Route = route, Result = route.Apply(request) }).ToList();
var matchingRoutes = routesApplied.Where(x => x.Result.Matches).ToList();
if (!matchingRoutes.Any())
{
var errors = string.Join(string.Empty, routesApplied.Select(x => "\r\n\t{0}:\t{1}".Fmt(x.Route.Path, x.Result.FailReason)));
var errMsg = "None of the given rest routes matches '{0}' request:{1}"
.Fmt(requestType.Name, errors);
throw new InvalidOperationException(errMsg);
}
var matchingRoute = matchingRoutes[0]; // hack to determine variable type.
if (matchingRoutes.Count > 1)
{
var mostSpecificRoute = FindMostSpecificRoute(matchingRoutes.Select(x => x.Route));
if (mostSpecificRoute == null)
{
var errors = string.Join(string.Empty, matchingRoutes.Select(x => "\r\n\t" + x.Route.Path));
var errMsg = "Ambiguous matching routes found for '{0}' request:{1}".Fmt(requestType.Name, errors);
throw new InvalidOperationException(errMsg);
}
matchingRoute = matchingRoutes.Single(x => x.Route == mostSpecificRoute);
}
else
{
matchingRoute = matchingRoutes.Single();
}
if (matchingRoute.Route.HttpMethods.Length != 1)
{
var verbs = matchingRoute.Route.HttpMethods.Length == 0
? "ALL"
: string.Join(", ", matchingRoute.Route.HttpMethods);
var msg = "Could not determine Http method for '{0}' request and '{1}' rest route. Given rest route accepts such HTTP verbs: {2}. Please specify the HTTP method explicitly."
.Fmt(requestType.Name, matchingRoute.Route.Path, verbs);
throw new InvalidOperationException(msg);
}
var httpMethod = matchingRoute.Route.HttpMethods.Single().ToUpperInvariant();
var url = matchingRoute.Result.Uri;
if (httpMethod == HttpMethod.Get || httpMethod == HttpMethod.Delete)
{
var queryParams = matchingRoute.Route.FormatQueryParameters(request);
if (!string.IsNullOrWhiteSpace(queryParams))
{
url += "?" + queryParams;
}
}
var response = Send<TResponse>(service, httpMethod, url, request);
return response;
}
private static T Send<T>(this IRestClient serviceClient, string httpMethod, string relativeUrl, object request)
{
switch (httpMethod.ToUpperInvariant())
{
case HttpMethod.Get:
return serviceClient.Get<T>(relativeUrl);
case HttpMethod.Post:
return serviceClient.Post<T>(relativeUrl, request);
case HttpMethod.Put:
return serviceClient.Put<T>(relativeUrl, request);
case HttpMethod.Delete:
return serviceClient.Delete<T>(relativeUrl);
case HttpMethod.Patch:
return serviceClient.Patch<T>(relativeUrl, request);
default:
throw new NotSupportedException("HttpMethod {0} is not supported by API");
}
}
private static List<RestRoute> GetRoutesForType(Type requestType)
{
var restRoutes = requestType.GetCustomAttributes(false)
.OfType<RestServiceAttribute>()
.Select(attr => new RestRoute(requestType, attr.Path, attr.Verbs))
.ToList();
return restRoutes;
}
private static RestRoute FindMostSpecificRoute(IEnumerable<RestRoute> routes)
{
routes = routes.ToList();
var mostSpecificRoute = routes.OrderBy(p => p.Variables.Count).Last();
// We may find several different routes {code}/{id} and {code}/{name} having the same number of variables.
// Such case will be handled by the next check.
var allPathesAreSubsetsOfMostSpecific = routes
.All(route => !route.Variables.Except(mostSpecificRoute.Variables).Any());
if (!allPathesAreSubsetsOfMostSpecific)
{
return null;
}
// Choose
// /product-lines/{productId}/{lineNumber}
// over
// /products/{productId}/product-lines/{lineNumber}
// (shortest one)
var shortestPath = routes
.Where(p => p.Variables.Count == mostSpecificRoute.Variables.Count)
.OrderBy(path => path.Path.Length)
.First();
return shortestPath;
}
}
public class RestRoute
{
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:FieldsMustBePrivate", Justification = "Using field is just easier.")]
public static Func<object, string> FormatVariable = value =>
{
var valueString = value as string;
return valueString != null ? Uri.EscapeDataString(valueString) : value.ToString();
};
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:FieldsMustBePrivate", Justification = "Using field is just easier.")]
public static Func<object, string> FormatQueryParameterValue = value =>
{
// Perhaps custom formatting needed for DateTimes, lists, etc.
var valueString = value as string;
return valueString != null ? Uri.EscapeDataString(valueString) : value.ToString();
};
private const char PathSeparatorChar = '/';
private const string VariablePrefix = "{";
private const char VariablePrefixChar = '{';
private const string VariablePostfix = "}";
private const char VariablePostfixChar = '}';
private readonly Dictionary<string, PropertyInfo> variablesMap = new Dictionary<string, PropertyInfo>();
public RestRoute(Type type, string path, string verbs)
{
this.HttpMethods = (verbs ?? string.Empty).Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
this.Type = type;
this.Path = path;
this.MapUrlVariablesToProperties();
this.Variables = this.variablesMap.Keys.Select(x => x.ToLowerInvariant()).Distinct().ToList().AsReadOnly();
}
public string ErrorMsg { get; set; }
public Type Type { get; set; }
public bool IsValid
{
get { return string.IsNullOrWhiteSpace(this.ErrorMsg); }
}
public string Path { get; set; }
public string[] HttpMethods { get; private set; }
public IList<string> Variables { get; set; }
public RouteResolutionResult Apply(object request)
{
if (!this.IsValid)
{
return RouteResolutionResult.Error(this.ErrorMsg);
}
var uri = this.Path;
var unmatchedVariables = new List<string>();
foreach (var variable in this.variablesMap)
{
var property = variable.Value;
var value = property.GetValue(request, null);
if (value == null)
{
unmatchedVariables.Add(variable.Key);
continue;
}
var variableValue = FormatVariable(value);
uri = uri.Replace(VariablePrefix + variable.Key + VariablePostfix, variableValue);
}
if (unmatchedVariables.Any())
{
var errMsg = "Could not match following variables: " + string.Join(",", unmatchedVariables);
return RouteResolutionResult.Error(errMsg);
}
return RouteResolutionResult.Success(uri);
}
public string FormatQueryParameters(object request)
{
string parameters = string.Empty;
foreach (var property in this.Type.GetProperties().Except(this.variablesMap.Values))
{
var value = property.GetValue(request, null);
if (value == null)
{
continue;
}
parameters += "&{0}={1}".Fmt(property.Name.ToCamelCase(), FormatQueryParameterValue(value));
}
if (!string.IsNullOrWhiteSpace(parameters))
{
parameters = parameters.Substring(1);
}
return parameters;
}
private void MapUrlVariablesToProperties()
{
// Perhaps other filters needed: do not include indexers, property should have public getter, etc.
var properties = this.Type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
var components = this.Path.Split(PathSeparatorChar);
foreach (var component in components)
{
if (string.IsNullOrWhiteSpace(component))
{
continue;
}
if (component.Contains(VariablePrefix) || component.Contains(VariablePostfix))
{
var variableName = component.Substring(1, component.Length - 2);
// Accept only variables matching this format: '/{property}/'
// Incorrect formats: '/{property/' or '/{property}-some-other-text/'
// I'm not sure that the second one will be parsed correctly at server side.
if (component[0] != VariablePrefixChar || component[component.Length - 1] != VariablePostfixChar || variableName.Contains(VariablePostfix))
{
this.AppendError("Component '{0}' can not be parsed".Fmt(component));
continue;
}
if (!this.variablesMap.ContainsKey(variableName))
{
var matchingProperties = properties
.Where(p => p.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase))
.ToList();
if (!matchingProperties.Any())
{
this.AppendError("Variable '{0}' does not match any property.".Fmt(variableName));
continue;
}
if (matchingProperties.Count > 1)
{
var msg = "Variable '{0}' matches '{1}' properties which are differ by case only."
.Fmt(variableName, matchingProperties.Count);
this.AppendError(msg);
continue;
}
this.variablesMap.Add(variableName, matchingProperties.Single());
}
}
}
}
private void AppendError(string msg)
{
if (string.IsNullOrWhiteSpace(this.ErrorMsg))
{
this.ErrorMsg = msg;
}
else
{
this.ErrorMsg += "\r\n" + msg;
}
}
public class RouteResolutionResult
{
public string FailReason { get; private set; }
public string Uri { get; private set; }
public bool Matches
{
get { return string.IsNullOrEmpty(this.FailReason); }
}
public static RouteResolutionResult Error(string errorMsg)
{
return new RouteResolutionResult { FailReason = errorMsg };
}
public static RouteResolutionResult Success(string uri)
{
return new RouteResolutionResult { Uri = uri };
}
}
}
}
namespace ServiceStack.Service.Tests
{
using System;
using System.IO;
using NUnit.Framework;
using ServiceStack.Service;
using ServiceStack.ServiceClient.Web;
using ServiceStack.ServiceHost;
using ServiceStack.Text;
[RestService("/customers/{id}", "GET")]
[RestService("/customers/by-code/{code}", "GET")]
public class GetCustomer : IRequest<GetCustomerResponse>
{
public int? Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
}
public class GetCustomerResponse
{
}
[RestService("/orders", Verbs = "POST")]
[RestService("/orders/{id}", Verbs = "PUT")]
public class SaveOrder : IRequest<SaveOrderResponse>
{
public int? Id { get; set; }
public string Description { get; set; }
}
public class SaveOrderResponse
{
}
[RestService("/orders/{orderId}/order-lines/{lineNumber}", "GET")]
[RestService("/order-lines/{orderId}/{lineNumber}", "GET")]
public class GetOrderLine : IRequest<GetOrderLineResponse>
{
public int LineNumber { get; set; }
public int OrderId { get; set; }
}
public class GetOrderLineResponse
{
}
[TestFixture]
public class RoutesResolvingTests
{
private RestClientMock mockClient;
[SetUp]
public void SetUp()
{
this.mockClient = new RestClientMock();
}
[Test]
public void Should_resolve_different_urls_for_different_properties()
{
SpyUrl(new GetCustomer { Code = "CustomerCode" }).ShouldEqual("GET /customers/by-code/CustomerCode");
SpyUrl(new GetCustomer { Id = 1 }).ShouldEqual("GET /customers/1");
}
[Test]
public void Should_throw_on_ambiguous_routes_match()
{
var ex = Catch.Exception(() => SpyUrl(new GetCustomer { Code = "CustomerCode", Id = 1 }));
ex.ShouldBeOfType<InvalidOperationException>()
.Message
.ShouldContain("Ambiguous matching routes found for '{0}' request:".Fmt(typeof(GetCustomer).Name))
.ShouldContain("/customers/{id}")
.ShouldContain("/customers/by-code/{code}");
}
[Test]
public void Should_throw_when_none_of_path_matches()
{
Catch.Exception(() => SpyUrl(new GetCustomer())).ShouldBeOfType<InvalidOperationException>()
.Message.ShouldEqual(@"None of the given rest routes matches 'GetCustomer' request:
/customers/by-code/{code}: Could not match following variables: code
/customers/{id}: Could not match following variables: id");
}
[Test]
public void Should_escape_matched_path_parts()
{
SpyUrl(new GetCustomer { Code = "* +" }).ShouldEqual("GET /customers/by-code/*%20%2B");
}
[Test]
public void Should_escape_non_matched_properties_and_append_them_as_url_parameters_for_GET_request()
{
SpyUrl(new GetCustomer { Code = "Code", Name = "? ?" }).ShouldEqual("GET /customers/by-code/Code?name=%3F%20%3F");
}
[Test]
public void Should_choose_most_specific_url_when_several_urls_matched()
{
SpyUrl(new SaveOrder { Id = 5 }).ShouldEqual("PUT /orders/5");
SpyUrl(new SaveOrder()).ShouldEqual("POST /orders");
}
[Test]
public void Should_choose_shortest_path_for_routes_with_same_variables()
{
SpyUrl(new GetOrderLine { OrderId = 1, LineNumber = 2 }).ShouldEqual("GET /order-lines/1/2");
}
[Test]
public void Should_send_request_dto_for_POST_and_PUT_requests()
{
var request = new SaveOrder();
mockClient.Send(request);
mockClient.Request.ShouldBeTheSameAs(request);
mockClient.HttpVerb.ShouldEqual("POST");
request = new SaveOrder { Id = 1 };
mockClient.Send(request);
mockClient.Request.ShouldBeTheSameAs(request);
mockClient.HttpVerb.ShouldEqual("PUT");
}
[Test]
public void Should_not_append_query_params_for_POST_and_PUT_requests()
{
SpyUrl(new SaveOrder { Description = "Description" }).ShouldNotContain("?").ShouldEqual("POST /orders");
SpyUrl(new SaveOrder { Id = 1, Description = "Description" }).ShouldNotContain("?").ShouldEqual("PUT /orders/1");
}
private string SpyUrl<T>(IRequest<T> request)
{
this.mockClient.Send(request);
return this.mockClient.HttpVerb + " " + this.mockClient.Url;
}
}
public class RestClientMock : IRestClient
{
public string Url { get; set; }
public string HttpVerb { get; set; }
public object Request { get; set; }
public TResponse Get<TResponse>(string relativeOrAbsoluteUrl)
{
HttpVerb = HttpMethod.Get;
Url = relativeOrAbsoluteUrl;
return default(TResponse);
}
public TResponse Delete<TResponse>(string relativeOrAbsoluteUrl)
{
HttpVerb = HttpMethod.Delete;
Url = relativeOrAbsoluteUrl;
return default(TResponse);
}
public TResponse Post<TResponse>(string relativeOrAbsoluteUrl, object request)
{
Request = request;
HttpVerb = HttpMethod.Post;
Url = relativeOrAbsoluteUrl;
return default(TResponse);
}
public TResponse Put<TResponse>(string relativeOrAbsoluteUrl, object request)
{
Request = request;
HttpVerb = HttpMethod.Put;
Url = relativeOrAbsoluteUrl;
return default(TResponse);
}
public TResponse Patch<TResponse>(string relativeOrAbsoluteUrl, object request)
{
HttpVerb = HttpMethod.Patch;
Url = relativeOrAbsoluteUrl;
return default(TResponse);
}
public TResponse PostFile<TResponse>(string relativeOrAbsoluteUrl, FileInfo fileToUpload, string mimeType)
{
throw new System.NotImplementedException();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment