I'm trying to Post a request using Restsharp Client (version 108.0.3), however I'm getting an error of "Bad Request: Some of mandatory fields are missing". I know the reason the error message is showing is due to the values not being passed / sent with the request. I have tried sending in the values multiple ways, however with only one way being successful (but, this way won't work as I need to do some logic before passing the entity).
For now, I'm trying to send in only 2 values:
[DataContract]
public class UserModel : IBdeoModel
{
[Required]
//[property: JsonPropertyName("name")]
[DataMember(Name = "email")]
public string ClientEmail { get; set; }
[Required]
//[property: JsonPropertyName("password")]
[DataMember(Name = "password")]
public string ClientSecrent { get; set; }
[IgnoreDataMember]
public bool IsNameValue => true;
}
When the code is run, the folling code will be the first step to "setup" some data. A transport model is created to do some logic for data conversion depending on type (e.g. string, int, datetime) - for this step it is primarily to get the password in the required format.
public class BdeoLoginAPI : ApiRoutable, IBdeoLoginApi
{
private readonly IClient _client;
public override string MidRoute => "/prod/v2/login";
public BdeoLoginAPI(IClient client)
{
_client = client;
}
/// <summary>
/// Authenticates the request to Bdeo
/// </summary>
/// <param cref="UserModel">User Auth Model</param>
/// <returns>The result set of properties created in hubspot.</returns>
public UserTokenModel<LoginAuthDetails> AuthenticateBdeo(UserModel entity)
{
string path = GetRoute<UserModel>();
BdeoTransportObject<UserModel> transportModel = new BdeoTransportObject<UserModel>(entity);
//return _client.Execute<UserTokenModel<LoginAuthDetails>, UserModel>(path, entity, Method.Post);
return _client.Execute<UserTokenModel<LoginAuthDetails>, BdeoTransportObject<UserModel>>(path, transportModel, Method.Post);
}
}
The transport model that is being called:
[DataContract]
public class BdeoTransportObject<T> : BdeoTransportModel<T>
{
public BdeoTransportObject() { }
public BdeoTransportObject(T model)
{
ToPropertyTransportModel(model);
}
}
[DataContract]
public abstract class BdeoTransportModel<T>
{
private Dictionary<string, object> Properties { get; set; }
internal void ToPropertyTransportModel(T model)
{
var propertiesToAdd = new Dictionary<string, object>();
PropertyInfo[] modelProperties = model.GetType().GetProperties();
foreach (PropertyInfo modelProp in modelProperties)
{
var memberAttrib = modelProp.GetCustomAttribute(typeof(DataMemberAttribute)) as DataMemberAttribute;
object modelValue = modelProp.GetValue(model);
if (modelValue == null || memberAttrib == null || modelValue.ToString() == string.Empty)
{
continue;
}
//some other logic
propertiesToAdd.Add(memberAttrib.Name, modelValue);
}
Properties = propertiesToAdd;
}
}
Then finally doing the actual RestRequest call.
public T Execute<T, K>(string absoluteUriPath, K entity, Method method = Method.Get) where T : new()
=> SendReceiveRequest<T, K>(absoluteUriPath, method, entity);
private RestRequest ConfigureRequestAuthentication(string path, Method method)
{
string fullPath = $"{BasePath.TrimEnd('/')}/{path.Trim('/')}";
RestRequest request = new RestRequest(fullPath, method);
request.RequestFormat = DataFormat.Json;
return request;
}
private T SendReceiveRequest<T, K>(string path, Method method, K entity) where T : new()
{
RestRequest request = ConfigureRequestAuthentication(path, method);
if (!entity.Equals(default(K)))
{
//string json = System.Text.Json.JsonSerializer.Serialize(entity);
//string json = JsonConvert.SerializeObject(entity);
//request.AddJsonBody(entity);
//string content = JsonConvert.SerializeObject(entity, Formatting.Indented);
request.AddBody(entity);
//request.AddParameter("text/json", entity, ParameterType.RequestBody);
}
var response = _client.Execute<T>(request);
if (!response.IsSuccessful)
{
throw new InvalidOperationException("Failed with " + response.StatusCode.ToString() + ". Error Message: " + response.ErrorMessage);
}
return response.Data;
}
When sending the entity through, the data is definately present, however it seems it is not serializing as it should and throwing the error of BadRequest - mandatory fields are missing.
However, when I take out the middle step for the transport model, it works fine. Making the necessarily alterations to get it to work looks like this:
[DataContract]
public class UserModel : IBdeoModel
{
[Required]
public string email { get; set; }
[Required]
public string password { get; set; }
[IgnoreDataMember]
public bool IsNameValue => true;
}
public UserTokenModel<LoginAuthDetails> AuthenticateBdeo(UserModel entity)
{
string path = GetRoute<UserModel>();
BdeoTransportObject<UserModel> transportModel = new BdeoTransportObject<UserModel>(entity);
return _client.Execute<UserTokenModel<LoginAuthDetails>, UserModel>(path, entity, Method.Post);
// return _client.Execute<UserTokenModel<LoginAuthDetails>, BdeoTransportObject<UserModel>>(path, transportModel, Method.Post);
}
It seems that adding these properties to a dictionary type in the transportmodel is causing the issue when Restharp attempts to serialize it, however I can't seem to see what is wrong to get it to work.
Related
I am working on a C# application, and I have 2 (soon to be 3, 4 and more) methods which have such a similar structure they are begging to be converted to something more generic. Here are 2 samples, you will see the similarities.
Method 1:
public async Task<APIGatewayProxyResponse> McaEventStoreRecvdPointsCouponProxyResponse(APIGatewayProxyRequest request, ILambdaContext context)
{
try
{
string thisRequestId = Guid.NewGuid().ToString();
if (request.PathParameters.Any())
{
var cardNumber = request.PathParameters.FirstOrDefault(x => x.Key.ToLower() == "card_number").Value;
context.Logger.LogLine($"MCA Event store event [{cardNumber}]");
var restValueVoucher = JsonConvert.DeserializeObject<RootObjectRestValueVoucherPayload>(request.Body);
RestValueVoucherPayloadValidator validator = new RestValueVoucherPayloadValidator();
ValidationResult results = validator.Validate(restValueVoucher.Payload);
if (!results.IsValid) throw new SchemaValidationException(results.Errors);
var dbRestValueVoucher = restValueVoucher.Payload.Convert(restValueVoucher.Payload);
dbRestValueVoucher.CardNumber = cardNumber;
loyaltyContext.Add(dbRestValueVoucher);
int rowsAffected = await loyaltyContext.SaveChangesAsync();
context.Logger.LogLine($"Database changes applied {rowsAffected}");
return GenerateResponse(HttpStatusCode.OK, new EventStoreResponse(context,
RequestResponseTypes.EVENT_STORE, thisRequestId,
restValueVoucher.Payload));
}
else
{
return GenerateResponse(HttpStatusCode.OK, new TestResponse(context, RequestResponseTypes.TEST_REQUEST));
}
}
catch (SchemaValidationException schemaEx)
{
context.Logger.LogLine(schemaEx.Message);
return GenerateResponse(HttpStatusCode.BadRequest, schemaEx);
}
catch (Exception ex)
{
context.Logger.LogLine($"{ex}");
LcsException lcsException = new LcsException(ex);
return GenerateResponse(HttpStatusCode.BadRequest,
lcsException);
}
}
Method 2:
public async Task<APIGatewayProxyResponse> McaEventStoreTierChangeProxyResponse(APIGatewayProxyRequest request, ILambdaContext context)
{
try
{
string thisRequestId = Guid.NewGuid().ToString();
if (request.PathParameters.Any())
{
var cardNumber = request.PathParameters.FirstOrDefault(x => x.Key.ToLower() == "card_number").Value;
context.Logger.LogLine($"MCA Event store event [{cardNumber}]");
var tierChange = JsonConvert.DeserializeObject<RootObjectTierChangePayload>(request.Body);
TierChangePayloadValidator validator = new TierChangePayloadValidator();
ValidationResult results = validator.Validate(tierChange.Payload);
if (!results.IsValid) throw new SchemaValidationException(results.Errors);
var dbTierChange = tierChange.Payload.Convert(tierChange.Payload);
dbTierChange.CardNumber = cardNumber;
loyaltyContext.Add(dbTierChange);
int rowsAffected = await loyaltyContext.SaveChangesAsync();
context.Logger.LogLine($"Database changes applied {rowsAffected}");
return GenerateResponse(HttpStatusCode.OK, new EventStoreResponse(context,
RequestResponseTypes.EVENT_STORE, thisRequestId,
tierChange.Payload));
}
else
{
return GenerateResponse(HttpStatusCode.OK, new TestResponse(context, RequestResponseTypes.TEST_REQUEST));
}
}
catch (SchemaValidationException schemaEx)
{
context.Logger.LogLine(schemaEx.Message);
return GenerateResponse(HttpStatusCode.BadRequest, schemaEx);
}
catch (Exception ex)
{
context.Logger.LogLine($"{ex}");
LcsException lcsException = new LcsException(ex);
return GenerateResponse(HttpStatusCode.BadRequest,
lcsException);
}
}
I started to work on the generic method, and got this far:
private static TPayload ProcessTest<TPayload, TEvent>(TPayload payload, TEvent myevent, string body, AbstractValidator<TPayload> validator)
where TPayload : Payload
where TEvent : IEventStore
{
var test = JsonConvert.DeserializeObject<TPayload>(body);
ValidationResult results = validator.Validate(?)
}
My issue is with refactoring this line at the moment: ValidationResult results = validator.Validate(tierChange.Payload). tierChange is a JSON 'Root object' that allows me to accept incoming JSON in the following format:
{
"Message": {
"message-id": 1000,
"old-tier": "SISTERCLUB",
"new-tier": "DIAMOND",
"timestamp-of-change": "2020-07-27T00:00:00",
"anniversary-date": "2020-07-28T00:00:00"
}
}
The structure is very similar to the incoming JSON for Method 1, which is:
{
"Message": {
"message-id": 10000,
"redeemed-voucher-instance-id":123,
"new-voucher-instance-id":1234,
"initial-voucher-value": 5.00,
"rest-voucher-value":15.00,
"valid-from": "2020-07-27T00:00:00",
"valid-to": "2021-07-27T00:00:00",
"description": "$5 BIRTHDAY VOUCHER",
"unit": "AUD"
}
}
The .Payload is used to access the content inside the root object in both cases, (content which is unique to each object). Here is an example of the Tier Change Root object and Payload (apart from different properties within Payload, the other object is the same).
The root object:
public class RootObjectTierChangePayload
{
[JsonProperty(PropertyName = "Message")]
public TierChangePayload Payload { get; set; }
}
And the inner object:
public partial class TierChangePayload : Payload, ITransform<TierChangePayload, TierChange>, IEventStore
{
[JsonProperty(PropertyName = "message-id")]
public int MessageId { get; set; }
/// <summary>
/// </summary>
[JsonProperty(PropertyName = "old-tier")]
public string OldTier { get; set; }
/// <summary>
/// </summary>
[JsonProperty(PropertyName = "new-tier")]
public string NewTier { get; set; }
/// <summary>
/// </summary>
[JsonProperty(PropertyName = "timestamp-of-change")]
public DateTime TimestampOfChange { get; set; }
/// <summary>
/// </summary>
[JsonProperty(PropertyName = "anniversary-date")]
public DateTime AnniversaryDate { get; set; }
public TierChange Convert(TierChangePayload source)
{
TierChange tierChange = new TierChange
{
CreatedTimestamp = Functions.GenerateDateTimeByLocale(),
ChangeTimestamp = null,
AnniversaryDate = this.AnniversaryDate,
MessageId = this.MessageId,
NewTierId = this.NewTier,
OldTierId = this.OldTier
};
return tierChange;
}
public string ToJson()
{
throw new NotImplementedException();
}
}
How can I adjust the objects I'm using so that I can better generalise them to suit the generic method? At the moment, I can't access .Payload in the generic method.
Update
In C# you can pass code blocks (delegates) to other code blocks as Action<T> type or Func<T> type (with a variable number of generic arguments).
Those types just encapsulate your code and are useful in the cases such as yours - where the method is almost the same save for a couple of lines. You can take those couple of lines and pass them as a parameter to the method.
Action<> is a code block that takes T arguments and returns void.
Func<> is a code block that takes 0 or several T1 arguments and returns a T result.
Note that when compiled, these code blocks turn into static methods, and are thus purely a syntactic sugar.
End update
So your generic method can look like this:
public async Task<APIGatewayProxyResponse> GenericMethod<T>(APIGatewayProxyRequest request, ILambdaContext context, Func<string, (T, ValidationResult, string)> validationFunc) where T: class
{
try
{
string thisRequestId = Guid.NewGuid().ToString();
if (request.PathParameters.Any())
{
var cardNumber = request.PathParameters.FirstOrDefault(x => x.Key.ToLower() == "card_number").Value;
context.Logger.LogLine($"MCA Event store event [{cardNumber}]");
var validationAndData = validationFunc(request.Body);
ValidationResult results = validationAndData.Item2;
if (!results.IsValid) throw new SchemaValidationException(results.Errors);
loyaltyContext.Add(validationAndData.Item1);
int rowsAffected = await loyaltyContext.SaveChangesAsync();
context.Logger.LogLine($"Database changes applied {rowsAffected}");
return GenerateResponse(HttpStatusCode.OK, new EventStoreResponse(context,
RequestResponseTypes.EVENT_STORE, thisRequestId,
validationAndData.Item3));
}
else
{
return GenerateResponse(HttpStatusCode.OK,
new TestResponse(context, RequestResponseTypes.TEST_REQUEST));
}
}
catch (SchemaValidationException schemaEx)
{
context.Logger.LogLine(schemaEx.Message);
return GenerateResponse(HttpStatusCode.BadRequest, schemaEx);
}
catch (Exception ex)
{
context.Logger.LogLine($"{ex}");
LcsException lcsException = new LcsException(ex);
return GenerateResponse(HttpStatusCode.BadRequest,
lcsException);
}
}
Then you can convert the other two like so:
public async Task<APIGatewayProxyResponse> McaEventStoreRecvdPointsCouponProxyResponse(APIGatewayProxyRequest request, ILambdaContext context)
{
return await GenericMethod(request, context, (body) => {
var restValueVoucher = JsonConvert.DeserializeObject<RootObjectRestValueVoucherPayload>(request.Body);
RestValueVoucherPayloadValidator validator = new RestValueVoucherPayloadValidator();
var dbRestValueVoucher = restValueVoucher.Payload.Convert(restValueVoucher.Payload);
dbRestValueVoucher.CardNumber = cardNumber;
return (dbRestValueVoucher, validator.Validate(restValueVoucher.Payload), restValueVoucher.Payload);
});
}
public async Task<APIGatewayProxyResponse> McaEventStoreTierChangeProxyResponse(APIGatewayProxyRequest request, ILambdaContext context)
{
return await GenericMethod(request, context, (body) => {
var tierChange = JsonConvert.DeserializeObject<RootObjectTierChangePayload>(request.Body);
TierChangePayloadValidator validator = new TierChangePayloadValidator();
var dbTierChange = tierChange.Payload.Convert(tierChange.Payload);
dbTierChange.CardNumber = cardNumber;
return (dbTierChange, validator.Validate(tierChange.Payload), tierChange.Payload);
});
}
If you make generic root object
public class RootObject<T>
{
[JsonProperty(PropertyName = "Message")]
public T Payload { get; set; }
}
It could work if the json can handle that.
var tierChange = JsonConvert.DeserializeObject<RootObject<TPayload>>(request.Body);
ValidationResult results = validator.Validate(tierChange.Payload);
We are using the OAuthAuthorizationServerProvider class to do authorization in our ASP.NET Web Api app.
If the provided username and password is invalid in GrantResourceOwnerCredentials, the call
context.SetError( "invalid_grant", "The user name or password is incorrect." );
Produces the following Json result:
{
"error": "invalid_grant",
"error_description": "The user name or password is incorrect."
}
Is there any way to customize this error result?
I would like to make it consistent with default error message format used in other parts of the API:
{
"message": "Some error occurred."
}
Is this possible to achieve with the OAuthAuthorizationServerProvider?
This is how I did it.
string jsonString = "{\"message\": \"Some error occurred.\"}";
// This is just a work around to overcome an unknown internal bug.
// In future releases of Owin, you may remove this.
context.SetError(new string(' ',jsonString.Length-12));
context.Response.StatusCode = 400;
context.Response.Write(jsonString);
+1 for Dasun's answer. Here is how I extended it a bit further.
public class ErrorMessage
{
public ErrorMessage(string message)
{
Message = message;
}
public string Message { get; private set; }
}
public static class ContextHelper
{
public static void SetCustomError(this OAuthGrantResourceOwnerCredentialsContext context, string errorMessage)
{
var json = new ErrorMessage(errorMessage).ToJsonString();
context.SetError(json);
context.Response.Write(json);
}
}
The .ToJsonString() is another extension method that uses the Newtonsoft.Json library.
public static string ToJsonString(this object obj)
{
return JsonConvert.SerializeObject(obj);
}
Usage:
context.SetCustomError("something went wrong");
1+ again for "user2325333" and "Dasun's" answer his solution, your answers are good but still there is an issue .
The Josn Tag still return {error:""}, thus I replace the context.Response.Body with empty MemoryStream
and here the work example
public static class ContextHelper
{
public static void SetCustomError(this OAuthGrantResourceOwnerCredentialsContext context,string error, string errorMessage)
{
var json = new ResponseMessage
{ Data = errorMessage, Message = error, IsError = true }.ToJsonString();
context.SetError(json);
context.Response.Write(json);
Invoke(context);
}
public static string ToJsonString(this object obj)
{
return JsonConvert.SerializeObject(obj);
}
static async Task Invoke(OAuthGrantResourceOwnerCredentialsContext context)
{
var owinResponseStream = new MemoryStream();
var customResponseBody = new System.Net.Http.StringContent(JsonConvert.SerializeObject(new ResponseMessage()));
var customResponseStream = await customResponseBody.ReadAsStreamAsync();
await customResponseStream.CopyToAsync(owinResponseStream);
context.Response.ContentType = "application/json";
context.Response.ContentLength = customResponseStream.Length;
context.Response.Body = owinResponseStream;
}
}
public class ResponseMessage
{
public bool IsError { get; set; }
public string Data { get; set; }
public string Message { get; set; }
}
for usage of this context
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
if (!context.Match.Passcode)
{
context.SetCustomError("invalid_grant", "Passcode is invalid.");
return;
}
}
The Result will be as
I have several Controllers/Repositories that expose a GetByID(int someInt) method. In most cases they work, but in a couple they fail with a 404 (not found) error.
For example, here is the code from a test client that calls two REST methods:
GetById("inventories");
. . .
GetById("sitemapping");
private void GetById(string typeName)
{
// Had to add the "constant" (1) to prevent very bad things from happening (wherein the runtime has delusions of being a runaway train (DeserializeObject() call otherwise fails if only returning one "record"))
string uri = string.Format("{0}/{1}/1", typeName, numericUpDownId.Value);
Popul8TheGrid(uri);
}
private JArray GetRESTData(string uri)
{
var webRequest = (HttpWebRequest) WebRequest.Create(uri);
var webResponse = (HttpWebResponse) webRequest.GetResponse();
var reader = new StreamReader(webResponse.GetResponseStream());
string s = reader.ReadToEnd();
return JsonConvert.DeserializeObject<JArray>(s);
}
private void Popul8TheGrid(string uri)
{
try
{
dataGridView1.DataSource = GetRESTData(BASE_URI + uri);
}
catch (WebException webex)
{
MessageBox.Show(string.Format("Eek, a mousey-pooh! ({0})", webex.Message));
}
}
The Repository methods that are ultimately called are virtually identical:
Inventory (working):
private readonly List<Inventory> inventories = new List<Inventory>();
. . .
public Inventory GetById(int ID)
{
return inventories.FirstOrDefault(p => p.Id == ID);
}
Inventory Model, wherein can be seen that "Id" is an int:
public class Inventory
{
[Key]
public int Id { get; set; }
[Required]
public string InventoryName { get; set; }
. . .
}
Sitemapping (not working / not found (404):
private readonly List<SiteMapping> siteMappings = new List<SiteMapping>();
. . .
public SiteMapping GetById(int ID)
{
return siteMappings.FirstOrDefault(s => s.site_no == ID);
}
SiteMapping Model, wherein can be seen that "site_no" is an int:
public class SiteMapping
{
public int site_no { get; set; }
public string location_num { get; set; }
}
Why would Inventories work and SiteMapping fail?
UPDATE
For Keith:
URL (examples) are:
http://localhost:28642/api/sitemapping/3
-and:
http://localhost:28642/api/inventories/42
And I can add that first one directly in my browser, and it works fine: it returns the expected "record" as XML in Chrome. It's just from the code shown that calling it fails.
As to routing configuration, I'm using Castle Windsor, and code like this in RepositoriesInstaller:
Component.For<IInventoryRepository>().ImplementedBy<InventoryRepository>().LifestylePerWebRequest(),
. . .
Component.For<ISiteMappingRepository>().ImplementedBy<SiteMappingRepository>().LifestylePerWebRequest(),
But this is not a/the problem, as related method calls work fine (I should have mentioned this: I can call the other SiteMapping methods, namely Count and GetAll, and all is well - it's only the "GetByID()" that fails.
UPDATE 2
Kiran, I'm not sure what you mean, but if you mean my Controllers' attribute routing, here they are:
Working (Inventory):
private readonly IInventoryRepository _inventoryRepository;
public InventoriesController(IInventoryRepository inventoryRepository)
{
if (inventoryRepository == null)
{
throw new ArgumentNullException("inventoryRepository");
}
_inventoryRepository = inventoryRepository;
}
[Route("api/Inventories/{ID:int}")]
public Inventory GetInventoryById(int ID)
{
return _inventoryRepository.GetById(ID);
}
Nonworking (as to the GetByID() method only) (SiteMapping):
public readonly ISiteMappingRepository _sitemappingsRepository;
public SiteMappingController(ISiteMappingRepository sitemappingsRepository)
{
if (sitemappingsRepository == null)
{
throw new ArgumentNullException("SiteMappingController");
}
_sitemappingsRepository = sitemappingsRepository;
}
[Route("api/SiteMapping/{ID:int}")]
public SiteMapping GetSiteMappingById(int ID)
{
return _sitemappingsRepository.GetById(ID);
}
I have a ASP.Net Web API controller which simply returns the list of users.
public sealed class UserController : ApiController
{
[EnableTag]
public HttpResponseMessage Get()
{
var userList= this.RetrieveUserList(); // This will return list of users
this.responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ObjectContent<List<UserViewModel>>(userList, new JsonMediaTypeFormatter())
};
return this.responseMessage;
}
}
and an action filter attribute class EnableTag which is responsible to manage ETag and cache:
public class EnableTag : System.Web.Http.Filters.ActionFilterAttribute
{
private static ConcurrentDictionary<string, EntityTagHeaderValue> etags = new ConcurrentDictionary<string, EntityTagHeaderValue>();
public override void OnActionExecuting(HttpActionContext context)
{
if (context != null)
{
var request = context.Request;
if (request.Method == HttpMethod.Get)
{
var key = GetKey(request);
ICollection<EntityTagHeaderValue> etagsFromClient = request.Headers.IfNoneMatch;
if (etagsFromClient.Count > 0)
{
EntityTagHeaderValue etag = null;
if (etags.TryGetValue(key, out etag) && etagsFromClient.Any(t => t.Tag == etag.Tag))
{
context.Response = new HttpResponseMessage(HttpStatusCode.NotModified);
SetCacheControl(context.Response);
}
}
}
}
}
public override void OnActionExecuted(HttpActionExecutedContext context)
{
var request = context.Request;
var key = GetKey(request);
EntityTagHeaderValue etag;
if (!etags.TryGetValue(key, out etag) || request.Method == HttpMethod.Put || request.Method == HttpMethod.Post)
{
etag = new EntityTagHeaderValue("\"" + Guid.NewGuid().ToString() + "\"");
etags.AddOrUpdate(key, etag, (k, val) => etag);
}
context.Response.Headers.ETag = etag;
SetCacheControl(context.Response);
}
private static void SetCacheControl(HttpResponseMessage response)
{
response.Headers.CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(60),
MustRevalidate = true,
Private = true
};
}
private static string GetKey(HttpRequestMessage request)
{
return request.RequestUri.ToString();
}
}
The above code create an attribute class to manage ETag. So on the first request, it will create a new E-Tag and for the subsequent request it will check whether any ETag is existed. If so, it will generate Not Modified HTTP Status and return back to client.
My problem is, I want to create a new ETag if there are changes in my user list, ex. a new user is added, or an existing user is deleted. and append it with the response. This can be tracked by the userList variable.
Currently, the ETag received from client and server are same from every second request, so in this case it will always generate Not Modified status, while I want it when actually nothing changed.
Can anyone guide me in this direction?
My requirement was to cache my web api JSON responses... And all the solutions provided don't have an easy "link" to where the data is generated - ie in the Controller...
So my solution was to create a wrapper "CacheableJsonResult" which generated a Response, and then added the ETag to the header. This allows a etag to be passed in when the controller method is generated and wants to return the content...
public class CacheableJsonResult<T> : JsonResult<T>
{
private readonly string _eTag;
private const int MaxAge = 10; //10 seconds between requests so it doesn't even check the eTag!
public CacheableJsonResult(T content, JsonSerializerSettings serializerSettings, Encoding encoding, HttpRequestMessage request, string eTag)
:base(content, serializerSettings, encoding, request)
{
_eTag = eTag;
}
public override Task<HttpResponseMessage> ExecuteAsync(System.Threading.CancellationToken cancellationToken)
{
Task<HttpResponseMessage> response = base.ExecuteAsync(cancellationToken);
return response.ContinueWith<HttpResponseMessage>((prior) =>
{
HttpResponseMessage message = prior.Result;
message.Headers.ETag = new EntityTagHeaderValue(String.Format("\"{0}\"", _eTag));
message.Headers.CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromSeconds(MaxAge)
};
return message;
}, cancellationToken);
}
}
And then, in your controller - return this object:
[HttpGet]
[Route("results/{runId}")]
public async Task<IHttpActionResult> GetRunResults(int runId)
{
//Is the current cache key in our cache?
//Yes - return 304
//No - get data - and update CacheKeys
string tag = GetETag(Request);
string cacheTag = GetCacheTag("GetRunResults"); //you need to implement this map - or use Redis if multiple web servers
if (tag == cacheTag )
return new StatusCodeResult(HttpStatusCode.NotModified, Request);
//Build data, and update Cache...
string newTag = "123"; //however you define this - I have a DB auto-inc ID on my messages
//Call our new CacheableJsonResult - and assign the new cache tag
return new CacheableJsonResult<WebsiteRunResults>(results, GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings, System.Text.UTF8Encoding.Default, Request, newTag);
}
}
private static string GetETag(HttpRequestMessage request)
{
IEnumerable<string> values = null;
if (request.Headers.TryGetValues("If-None-Match", out values))
return new EntityTagHeaderValue(values.FirstOrDefault()).Tag;
return null;
}
You need to define how granular to make your tags; my data is user-specific, so I include the UserId in the CacheKey (etag)
a good solution for ETag and in ASP.NET Web API is to use CacheCow . A good article is here.
It's easy to use and you don't have to create a custom Attribute.
Have fun
.u
I found CacheCow very bloated for what it does, if the only reason is, to lower the amount of data transfered, you might want to use something like this:
public class EntityTagContentHashAttribute : ActionFilterAttribute
{
private IEnumerable<string> _receivedEntityTags;
private readonly HttpMethod[] _supportedRequestMethods = {
HttpMethod.Get,
HttpMethod.Head
};
public override void OnActionExecuting(HttpActionContext context) {
if (!_supportedRequestMethods.Contains(context.Request.Method))
throw new HttpResponseException(context.Request.CreateErrorResponse(HttpStatusCode.PreconditionFailed,
"This request method is not supported in combination with ETag."));
var conditions = context.Request.Headers.IfNoneMatch;
if (conditions != null) {
_receivedEntityTags = conditions.Select(t => t.Tag.Trim('"'));
}
}
public override void OnActionExecuted(HttpActionExecutedContext context)
{
var objectContent = context.Response.Content as ObjectContent;
if (objectContent == null) return;
var computedEntityTag = ComputeHash(objectContent.Value);
if (_receivedEntityTags.Contains(computedEntityTag))
{
context.Response.StatusCode = HttpStatusCode.NotModified;
context.Response.Content = null;
}
context.Response.Headers.ETag = new EntityTagHeaderValue("\"" + computedEntityTag + "\"", true);
}
private static string ComputeHash(object instance) {
var cryptoServiceProvider = new MD5CryptoServiceProvider();
var serializer = new DataContractSerializer(instance.GetType());
using (var memoryStream = new MemoryStream())
{
serializer.WriteObject(memoryStream, instance);
cryptoServiceProvider.ComputeHash(memoryStream.ToArray());
return String.Join("", cryptoServiceProvider.Hash.Select(c => c.ToString("x2")));
}
}
}
No need for setting up anything, set and forget. The way i like it. :)
I like the answer which was provided by #Viezevingertjes. It is the most elegant and "No need for setting up anything" approach is very convenient. I like it too :)
However I think it has a few drawbacks:
The whole OnActionExecuting() method and storing ETags in _receivedEntityTags is unnecessary because the Request is available inside the OnActionExecuted method as well.
Only works with ObjectContent response types.
Extra work load because of the serialization.
Also it was not part of the question and nobody mentioned it. But ETag should be used for Cache validation. Therefore it should be used with Cache-Control header so clients don't even have to call the server until the cache expires (it can be very short period of time depends on your resource). When the cache expired then client makes a request with ETag and validate it. For more details about caching see this article.
So that's why I decided to pimp it up a little but. Simplified filter no need for OnActionExecuting method, works with Any response types, no Serialization. And most importantly adds CacheControl header as well. It can be improved e.g. with Public cache enabled, etc... However I strongly advise you to understand caching and modify it carefully. If you use HTTPS and the endpoints are secured then this setup should be fine.
/// <summary>
/// Enables HTTP Response CacheControl management with ETag values.
/// </summary>
public class ClientCacheWithEtagAttribute : ActionFilterAttribute
{
private readonly TimeSpan _clientCache;
private readonly HttpMethod[] _supportedRequestMethods = {
HttpMethod.Get,
HttpMethod.Head
};
/// <summary>
/// Default constructor
/// </summary>
/// <param name="clientCacheInSeconds">Indicates for how long the client should cache the response. The value is in seconds</param>
public ClientCacheWithEtagAttribute(int clientCacheInSeconds)
{
_clientCache = TimeSpan.FromSeconds(clientCacheInSeconds);
}
public override async Task OnActionExecutedAsync(HttpActionExecutedContext actionExecutedContext, CancellationToken cancellationToken)
{
if (!_supportedRequestMethods.Contains(actionExecutedContext.Request.Method))
{
return;
}
if (actionExecutedContext.Response?.Content == null)
{
return;
}
var body = await actionExecutedContext.Response.Content.ReadAsStringAsync();
if (body == null)
{
return;
}
var computedEntityTag = GetETag(Encoding.UTF8.GetBytes(body));
if (actionExecutedContext.Request.Headers.IfNoneMatch.Any()
&& actionExecutedContext.Request.Headers.IfNoneMatch.First().Tag.Trim('"').Equals(computedEntityTag, StringComparison.InvariantCultureIgnoreCase))
{
actionExecutedContext.Response.StatusCode = HttpStatusCode.NotModified;
actionExecutedContext.Response.Content = null;
}
var cacheControlHeader = new CacheControlHeaderValue
{
Private = true,
MaxAge = _clientCache
};
actionExecutedContext.Response.Headers.ETag = new EntityTagHeaderValue($"\"{computedEntityTag}\"", false);
actionExecutedContext.Response.Headers.CacheControl = cacheControlHeader;
}
private static string GetETag(byte[] contentBytes)
{
using (var md5 = MD5.Create())
{
var hash = md5.ComputeHash(contentBytes);
string hex = BitConverter.ToString(hash);
return hex.Replace("-", "");
}
}
}
Usage e.g: with 1 min client side caching:
[ClientCacheWithEtag(60)]
Seems to be a nice way to do it:
public class CacheControlAttribute : System.Web.Http.Filters.ActionFilterAttribute
{
public int MaxAge { get; set; }
public CacheControlAttribute()
{
MaxAge = 3600;
}
public override void OnActionExecuted(HttpActionExecutedContext context)
{
if (context.Response != null)
{
context.Response.Headers.CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromSeconds(MaxAge)
};
context.Response.Headers.ETag = new EntityTagHeaderValue(string.Concat("\"", context.Response.Content.ReadAsStringAsync().Result.GetHashCode(), "\""),true);
}
base.OnActionExecuted(context);
}
}
Why is the response below always null in my test?
SSO.cs
public class SSO : ISSO
{
const string SSO_URL = "http://localhost";
const string SSO_PROFILE_URL = "http://localhost";
public AuthenticateResponse Authenticate(string userName, string password)
{
return GetResponse(SSO_URL);
}
public void GetProfile(string key)
{
throw new NotImplementedException();
}
public virtual AuthenticateResponse GetResponse(string url)
{
return new AuthenticateResponse();
}
}
public class AuthenticateResponse
{
public bool Expired { get; set; }
}
SSOTest.cs
[TestMethod()]
public void Authenticate_Expired_ReturnTrue()
{
var target = MockRepository.GenerateStub<SSO>();
AuthenticateResponse authResponse = new AuthenticateResponse() { Expired = true };
target.Expect(t => t.GetResponse("")).Return(authResponse);
target.Replay();
var response = target.Authenticate("mflynn", "password");
Assert.IsTrue(response.Expired);
}
Your expectation is not correct. You defined that you expect an empty string as parameter on GetResponse, but you pass in the value SSO_URL. So the expectation is not meet and null is returned instead.
You have two options to correct this
One way is to set IgnoreArguments() on the expectation
target.Expect(t => t.GetResponse("")).IgnoreArguments().Return(authResponse);
and the other way is to pass in your SSO_URL as parameter to the GetResponse method like this
target.Expect(t => t.GetResponse("http://localhost")).Return(authResponse);