Created
August 26, 2012 20:02
-
-
Save vansha/3483193 to your computer and use it in GitHub Desktop.
ServiceStack RestService extension allowing to call REST services in a generic way.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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