Part of a project I'm getting started on requires access to the Stack Exchange API for certain data, as a result I built a .NET implementation to interact with it.
The implementation is pretty simple, in my opinion. You can also find it on GitHub.
It features a main Configuration
class, in which you put a lot of the API configuration information.
/// <summary>
/// Represents a Stack Exchange API configuration for use with API requests.
/// </summary>
public class Configuration
{
/// <summary>
/// Represents the base endpoint for the Stack Exchange API url.
/// </summary>
public const string ApiUrlBase = "{Protocol}://api.stackexchange.com/{Version}/";
/// <summary>
/// This is the upper bound of the Page Size for <b>most</b> requests. Currently 100.
/// </summary>
public const int MaxPageSize = 100;
/// <summary>
/// The application API key. Can be <code>null</code> for anonymous requests.
/// </summary>
public string Key { get; set; }
/// <summary>
/// If true then the HTTPS protocol will be used, otherwise the HTTP protocol will be used. Defaults to true.
/// </summary>
public bool UseHttps { get; set; } = true;
/// <summary>
/// Determines what version of the API will be used. This should never be modified unless absolutely necessary. Defaults to 2.2.
/// </summary>
public string Version { get; set; } = "2.2";
/// <summary>
/// Returns the <see cref="ApiUrlBase"/> formatted with the provided parameters.
/// </summary>
public string FormattedUrl => ApiUrlBase.Replace("{Protocol}", UseHttps ? "https" : "http").Replace("{Version}", Version);
/// <summary>
/// Appends the current <see cref="Key"/> to the provided url.
/// </summary>
/// <param name="url">The URL to append to. Should be the result of <see cref="FormattedUrl"/>, then the API </param>
/// <returns></returns>
public string AppendKey(string url) => string.IsNullOrWhiteSpace(Key) ? url : url + (url.Contains('?') ? '&' : '?') + "key=" + Key;
/// <summary>
/// Returns the fully formatted URL for Stack Exchange API requests.
/// </summary>
/// <param name="requester">The fully filled <see cref="IRequest"/> making the request.</param>
/// <returns>The formatted url.</returns>
public string GetFormattedUrl<T>(IRequest<T> requester) where T : IBaseModel => AppendKey(FormattedUrl + requester.FormattedEndpoint);
}
You don't actually have to put anything in any of these fields. You can access the API with a default instance of this class as an anonymous user.
Then, as you can see, there is an IRequest<T>
interface, which specifies what a request object must contain.
/// <summary>
/// This representes a generic request against the Stack Exchange API. Though this does not make use of the type parameter intrinsically, it's necessary for generic inference and type constraints.
/// </summary>
/// <typeparam name="T">A <see cref="IBaseModel"/> representing the returned model from the request. When used with <see cref="Handler.SubmitRequest{T}(IRequest{T}, bool)"/> this will return a type of <see cref="Wrapper{TObject}"/> where <code>TObject</code> is this type.</typeparam>
public interface IRequest<T>
where T : IBaseModel
{
/// <summary>
/// The basic endpoint for the <see cref="IRequest{T}"/>.
/// </summary>
string EndpointUrl { get; }
/// <summary>
/// Gets the formatted endpoint for the <see cref="IRequest{T}"/>. This should <b>NOT</b> contain the Stack Exchange API base URL or key.
/// </summary>
string FormattedEndpoint { get; }
/// <summary>
/// This should verify that all the provided parameters required for the <see cref="IRequest{T}"/> are present.
/// </summary>
/// <returns>True if the required parameters pass verification, false otherwise.</returns>
bool VerifyRequiredParameters();
/// <summary>
/// This should return a message to be used to indicate to the user what the verification should be.
/// </summary>
string VerificationError { get; }
}
This interface allows us to interact more generically with an API request. It provides a few features we need to guarantee that we are at least attempting to submit a valid API request.
Next we have a Handler
class which actually submits and processes the API request.
/// <summary>
/// Fires and processes the actual SE API requests.
/// </summary>
public class Handler
{
/// <summary>
/// The <see cref="Configuration"/> to use for general API access.
/// </summary>
public Configuration Configuration { get; }
/// <summary>
/// Creates a new instance of the <see cref="Handler"/> with the specified <see cref="Configuration"/>.
/// </summary>
/// <param name="configuration">The <see cref="Configuration"/> to use for SE API requests.</param>
public Handler(Configuration configuration)
{
Configuration = configuration;
}
/// <summary>
/// Submits a request to the SE API.
/// </summary>
/// <typeparam name="T">The type of the object to be returned. This should be inferred from the <see cref="IRequest{T}"/>.</typeparam>
/// <param name="request">The <see cref="IRequest{T}"/> being performed.</param>
/// <param name="throwVerificationExceptions">If true, verification errors will result in exceptions. If false, a <code>null</code> object will simply be returned.</param>
/// <returns>A <see cref="Wrapper{TObject}"/> for the API request.</returns>
public Wrapper<T> SubmitRequest<T>(IRequest<T> request, bool throwVerificationExceptions = true)
where T : IBaseModel
{
if (!request.VerifyRequiredParameters())
{
if (throwVerificationExceptions)
{
throw new ArgumentException($"At least one of the required parameters for {nameof(request)} was invalid.", new ArgumentException(request.VerificationError));
}
else
{
return null;
}
}
var response = "";
var url = Configuration.GetFormattedUrl(request);
var webRequest = (HttpWebRequest)WebRequest.Create(url);
webRequest.Headers.Add(HttpRequestHeader.AcceptEncoding, "gzip,deflate");
webRequest.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
using (var webResponse = webRequest.GetResponse())
using (var sr = new StreamReader(webResponse.GetResponseStream()))
{
response = sr.ReadToEnd();
}
return DataContractJsonSerialization.Deserialize<Wrapper<T>>(response);
}
}
In order for all these type constraints to work, we have a generic IBaseModel
which does nothing except provide a type constraint.
/// <summary>
/// Represents a generic model for a Stack Exchange API request. Only required (at the moment) for type constraints.
/// </summary>
public interface IBaseModel
{
}
We have a Wrapper<T>
model which represents the base result from an SE API request. All responses from the API (as of now) return this Wrapper with the Items
field set to strong-types of the data returned. So, we use a generic implementation so we can provide the strong type ourselves.
/// <summary>
/// This is a general wrapper for Stack Exchange API request models. All API requests should contain these basic fields.
/// </summary>
/// <typeparam name="T">The type of the object list/array returned by the API request.</typeparam>
[DataContract]
public class Wrapper<T> : IBaseModel
where T : IBaseModel
{
/// <summary>
/// A list of the objects returned by the API request.
/// </summary>
[DataMember(Name = "items")]
public List<T> Items { get; set; }
/// <summary>
/// Whether or not <see cref="Items"/> returned by this request are the end of the pagination or not.
/// </summary>
[DataMember(Name = "has_more")]
public bool HasMore { get; set; }
/// <summary>
/// The maximum number of API requests that can be performed in a 24 hour period.
/// </summary>
[DataMember(Name = "quota_max")]
public int QuotaMax { get; set; }
/// <summary>
/// The remaining number of API requests that can be performed in the current 24 hour period.
/// </summary>
/// <remarks>
/// As far as I know, this resets to <see cref="QuotaMax"/> at 00:00:00 UTC+0000.
/// </remarks>
[DataMember(Name = "quota_remaining")]
public int QuotaRemaining { get; set; }
/// <summary>
/// The optional number of seconds that the programme making the API requests should stop submitting requests for.
/// </summary>
/// <remarks>
/// Programmes that fail to follow this backoff may be subject to being banned from making API requests for any period of time.
/// </remarks>
[DataMember(Name = "backoff")]
public int? Backoff { get; set; }
}
Then, we have some type of IRequest<T>
object which contains the actual data for the API request.
Here are all the currently implemented IRequest<T>
models.
/// <summary>
/// Represents an API request for Stack Exchange Site information.
/// </summary>
public class InfoRequest : IRequest<Info>
{
private const string _endpointUrl = "info?";
/// <summary>
/// The destination endpoint for the API request.
/// </summary>
public string EndpointUrl => _endpointUrl;
/// <summary>
/// The Stack Exchange Site to query the <see cref="Info"/> for.
/// </summary>
public string Site { get; set; }
/// <summary>
/// The final endpoint URL that should be appended to the Stack Exchange API base url.
/// </summary>
public string FormattedEndpoint
{
get
{
var values = new Dictionary<string, string>();
values.Add(nameof(Site).ToLower(), Site);
var qs = StringExtensions.BuildQueryString(values);
return EndpointUrl + qs;
}
}
/// <summary>
/// Returns whether or not the <see cref="Site"/> passed verification.
/// </summary>
/// <returns>True if <see cref="Site"/> is not a null, empty or whitespace string, false otherwise.</returns>
public bool VerifyRequiredParameters() => !string.IsNullOrWhiteSpace(Site);
/// <summary>
/// Gets a message indicating how <see cref="Site"/> is validated.
/// </summary>
public string VerificationError => $"The value for {nameof(Site)} must be a valid, non-null, and non-whitespace string.";
}
/// <summary>
/// Submits a request to the sites API endpoint, to return a <see cref="Site"/> object.
/// </summary>
/// <remarks>
/// Endpoint URL is <see cref="EndpointUrl"/>.
/// </remarks>
public class SitesRequest : IRequest<Site>
{
private const string _endpointUrl = "sites?";
/// <summary>
/// The destination endpoint for the API request.
/// </summary>
public string EndpointUrl => _endpointUrl;
/// <summary>
/// Determines how many sites will be returned for each page.
/// </summary>
/// <remarks>
/// As of this writing, this endpoint value is unbounded. Defaults to 1000.
/// </remarks>
public int PageSize { get; set; } = 1000;
public int Page { get; set; } = 1;
/// <summary>
/// Returns the fully formatted endpoint for this <see cref="SitesRequest"/> instance.
/// </summary>
public string FormattedEndpoint
{
get
{
var values = new Dictionary<string, string>();
values.Add(nameof(PageSize).ToLower(), PageSize.ToString());
values.Add(nameof(Page).ToLower(), Page.ToString());
var qs = StringExtensions.BuildQueryString(values);
return EndpointUrl + qs;
}
}
/// <summary>
/// Verifies that the <see cref="PageSize"/> is a valid value.
/// </summary>
/// <returns>True if <see cref="PageSize"/> is greater than 0, false otherwise.</returns>
public bool VerifyRequiredParameters() => PageSize > 0;
/// <summary>
/// Gets a message indicating how <see cref="PageSize"/> is validated.
/// </summary>
public string VerificationError => $"The value for {nameof(PageSize)} must be an integer greater than 0.";
}
public class BadgeRequest : IRequest<Badge>
{
private const string _endpointUrl = "badges?";
public string EndpointUrl => _endpointUrl;
public OrderType Order { get; set; }
public SortType Sort { get; set; }
public string Site { get; set; }
public int PageSize { get; set; } = 10;
public int Page { get; set; } = 1;
public string Min { get; set; }
public string Max { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public string FormattedEndpoint
{
get
{
var values = new Dictionary<string, string>();
values.Add(nameof(Order).ToLower(), Order == OrderType.Ascending ? "asc" : "desc");
values.Add(nameof(Sort).ToLower(), Sort.ToString().ToLower());
values.Add(nameof(Site).ToLower(), Site);
values.Add(nameof(PageSize).ToLower(), PageSize.ToString());
values.Add(nameof(Page).ToLower(), Page.ToString());
if (Min != null)
{
values.Add(nameof(Min).ToLower(), Min.ToString());
}
if (Min != null)
{
values.Add(nameof(Max).ToLower(), Max.ToString());
}
if (ToDate != null)
{
values.Add(nameof(ToDate).ToLower(), DateTimeExtensions.ToEpoch(ToDate.Value).ToString());
}
if (FromDate != null)
{
values.Add(nameof(FromDate).ToLower(), DateTimeExtensions.ToEpoch(FromDate.Value).ToString());
}
var qs = StringExtensions.BuildQueryString(values);
return EndpointUrl + qs;
}
}
public string VerificationError => $"The value for {nameof(Site)} must be a valid, non-null, and non-whitespace string; the value for {nameof(PageSize)} must be greater than 0 and less than or equal to {Configuration.MaxPageSize}.";
public bool VerifyRequiredParameters() => !string.IsNullOrWhiteSpace(Site) && PageSize > 0 && PageSize <= Configuration.MaxPageSize;
public enum SortType
{
Rank,
Name,
Type,
}
}
The OrderType
is a simple enum:
public enum OrderType
{
Ascending,
Descending,
}
It's in a different file as I may need to use it in other requests.
Finally, there is some type of model that is returned by the API request. Here are all the currently implemented models:
/// <summary>
/// Represents a badge from the Stack Exchange API.
/// </summary>
/// <remarks>
/// http://api.stackexchange.com/docs/types/badge
/// </remarks>
[DataContract]
public class Badge : IBaseModel
{
/// <summary>
/// See <code>award_count</code>
/// </summary>
[DataMember(Name = "award_count")]
public int AwardCount { get; set; }
/// <summary>
/// See <code>badge_id</code>
/// </summary>
[DataMember(Name = "badge_id")]
public int BadgeId { get; set; }
/// <summary>
/// See <code>badge_type</code>
/// </summary>
[DataMember(Name = "badge_type")]
public string BadgeType { get; set; }
/// <summary>
/// See <code>link</code>
/// </summary>
[DataMember(Name = "link")]
public string Link { get; set; }
/// <summary>
/// See <code>name</code>
/// </summary>
[DataMember(Name = "name")]
public string Name { get; set; }
/// <summary>
/// See <code>rank</code>
/// </summary>
[DataMember(Name = "rank")]
public string Rank { get; set; }
/// <summary>
/// See <code>user</code>
/// </summary>
[DataMember(Name = "user")]
public ShallowUser User { get; set; }
}
/// <summary>
/// Represents certain statistical data about a Stack Exchange <see cref="Site"/>.
/// </summary>
/// <remarks>
/// http://api.stackexchange.com/docs/types/info
/// </remarks>
[DataContract]
public class Info : IBaseModel
{
/// <summary>
/// See <code>answers_per_minute</code>
/// </summary>
[DataMember(Name = "answers_per_minute")]
public decimal AnswersPerMinute { get; set; }
/// <summary>
/// See <code>api_revision</code>
/// </summary>
[DataMember(Name = "api_revision")]
public string ApiRevision { get; set; }
/// <summary>
/// See <code>badges_per_minute</code>
/// </summary>
[DataMember(Name = "badges_per_minute")]
public decimal BadgesPerMinute { get; set; }
/// <summary>
/// See <code>new_active_users</code>
/// </summary>
[DataMember(Name = "new_active_users")]
public int NewActiveUsers { get; set; }
/// <summary>
/// See <code>questions_per_minute</code>
/// </summary>
[DataMember(Name = "questions_per_minute")]
public decimal QuestionsPerMinute { get; set; }
/// <summary>
/// See <code>total_accepted</code>
/// </summary>
[DataMember(Name = "total_accepted")]
public int TotalAccepted { get; set; }
/// <summary>
/// See <code>total_answers</code>
/// </summary>
[DataMember(Name = "total_answers")]
public int TotalAnswers { get; set; }
/// <summary>
/// See <code>total_badges</code>
/// </summary>
[DataMember(Name = "total_badges")]
public int TotalBadges { get; set; }
/// <summary>
/// See <code>total_comments</code>
/// </summary>
[DataMember(Name = "total_comments")]
public int TotalComments { get; set; }
/// <summary>
/// See <code>total_questions</code>
/// </summary>
[DataMember(Name = "total_questions")]
public int TotalQuestions { get; set; }
/// <summary>
/// See <code>total_unanswered</code>
/// </summary>
[DataMember(Name = "total_unanswered")]
public int TotalUnanswered { get; set; }
/// <summary>
/// See <code>total_users</code>
/// </summary>
[DataMember(Name = "total_users")]
public int TotalUsers { get; set; }
/// <summary>
/// See <code>total_votes</code>
/// </summary>
[DataMember(Name = "total_votes")]
public int TotalVotes { get; set; }
}
/// <summary>
/// Represents a site relation to a <see cref="Site"/> in the <see cref="Site.RelatedSites"/> list.
/// </summary>
/// <remarks>
/// http://api.stackexchange.com/docs/types/related-site
/// </remarks>
[DataContract]
public class RelatedSite : IBaseModel
{
/// <summary>
/// See <code>api_site_parameter</code>
/// </summary>
[DataMember(Name = "api_site_parameter")]
public string ApiSiteParameter { get; set; }
/// <summary>
/// See <code>name</code>
/// </summary>
[DataMember(Name = "name")]
public string Name { get; set; }
/// <summary>
/// See <code>relation</code>
/// </summary>
[DataMember(Name = "relation")]
public string Relation { get; set; }
/// <summary>
/// See <code>site_url</code>
/// </summary>
[DataMember(Name = "site_url")]
public string SiteUrl { get; set; }
}
/// <summary>
/// Represents a partial user on the Stack Exchange API.
/// </summary>
/// <remarks>
/// http://api.stackexchange.com/docs/types/shallow-user
/// </remarks>
[DataContract]
public class ShallowUser
{
/// <summary>
/// See <code>accept_rate</code>
/// </summary>
[DataMember(Name = "accept_rate")]
public int? AcceptRate { get; set; }
/// <summary>
/// See <code>display_name</code>
/// </summary>
[DataMember(Name = "display_name")]
public string DisplayName { get; set; }
/// <summary>
/// See <code>link</code>
/// </summary>
[DataMember(Name = "link")]
public string Link { get; set; }
/// <summary>
/// See <code>profile_image</code>
/// </summary>
[DataMember(Name = "profile_image")]
public string ProfileImage { get; set; }
/// <summary>
/// See <code>reputation</code>
/// </summary>
[DataMember(Name = "reputation")]
public int? Reputation { get; set; }
/// <summary>
/// See <code>user_id</code>
/// </summary>
[DataMember(Name = "user_id")]
public int? UserId { get; set; }
/// <summary>
/// See <code>user_type</code>
/// </summary>
[DataMember(Name = "user_type")]
public string UserType { get; set; }
}
/// <summary>
/// Represents a Stack Exchange site.
/// </summary>
/// <remarks>
/// http://api.stackexchange.com/docs/types/site
/// </remarks>
[DataContract]
public class Site : IBaseModel
{
/// <summary>
/// See <code>aliases</code>
/// </summary>
[DataMember(Name = "aliases")]
public List<string> Aliases { get; set; }
/// <summary>
/// See <code>api_site_parameter</code>
/// </summary>
[DataMember(Name = "api_site_parameter")]
public string ApiSiteParameter { get; set; }
/// <summary>
/// See <code>audience</code>
/// </summary>
[DataMember(Name = "audience")]
public string Audience { get; set; }
/// <summary>
/// See <code>closed_beta_date</code>
/// </summary>
[DataMember(Name = "closed_beta_date")]
public long? ClosedBetaDate { get; set; }
/// <summary>
/// A .NET DateTime? representing the <see cref="ClosedBetaDate"/>.
/// </summary>
public DateTime? ClosedBetaDateTime { get { return DateTimeExtensions.FromEpoch(ClosedBetaDate); } set { ClosedBetaDate = DateTimeExtensions.ToEpoch(value); } }
/// <summary>
/// See <code>favicon_url</code>
/// </summary>
[DataMember(Name = "favicon_url")]
public string FaviconUrl { get; set; }
/// <summary>
/// See <code>high_resolution_icon_url</code>
/// </summary>
[DataMember(Name = "high_resolution_icon_url")]
public string HighResolutionIconUrl { get; set; }
/// <summary>
/// See <code>icon_url</code>
/// </summary>
[DataMember(Name = "icon_url")]
public string IconUrl { get; set; }
/// <summary>
/// See <code>launch_date</code>
/// </summary>
[DataMember(Name = "launch_date")]
public long LaunchDate { get; set; }
/// <summary>
/// A .NET DateTime representing the <see cref="LaunchDate"/>.
/// </summary>
public DateTime LaunchDateTime { get { return DateTimeExtensions.FromEpoch(LaunchDate); } set { LaunchDate = DateTimeExtensions.ToEpoch(value); } }
/// <summary>
/// See <code>logo_url</code>
/// </summary>
[DataMember(Name = "logo_url")]
public string LogoUrl { get; set; }
/// <summary>
/// See <code>markdown_extensions</code>
/// </summary>
[DataMember(Name = "markdown_extensions")]
public List<string> MarkdownExtensions { get; set; }
/// <summary>
/// See <code>name</code>
/// </summary>
[DataMember(Name = "name")]
public string Name { get; set; }
/// <summary>
/// See <code>open_beta_date</code>
/// </summary>
[DataMember(Name = "open_beta_date")]
public long? OpenBetaDate { get; set; }
/// <summary>
/// A .NET DateTime? representing the <see cref="OpenBetaDate"/>.
/// </summary>
public DateTime? OpenBetaDateTime { get { return DateTimeExtensions.FromEpoch(OpenBetaDate); } set { OpenBetaDate = DateTimeExtensions.ToEpoch(value); } }
/// <summary>
/// See <code>related_sites</code>
/// </summary>
[DataMember(Name = "related_sites")]
public List<RelatedSite> RelatedSites { get; set; }
/// <summary>
/// See <code>site_state</code>
/// </summary>
[DataMember(Name = "site_state")]
public string SiteState { get; set; }
/// <summary>
/// See <code>site_type</code>
/// </summary>
[DataMember(Name = "site_type")]
public string SiteType { get; set; }
/// <summary>
/// See <code>site_url</code>
/// </summary>
[DataMember(Name = "site_url")]
public string SiteUrl { get; set; }
/// <summary>
/// See <code>styling</code>
/// </summary>
[DataMember(Name = "styling")]
public Styling Styling { get; set; }
/// <summary>
/// See <code>twitter_account</code>
/// </summary>
[DataMember(Name = "twitter_account")]
public string TwitterAccount { get; set; }
}
/// <summary>
/// Represents the <see cref="Site.Styling"/>.
/// </summary>
/// <remarks>
/// http://api.stackexchange.com/docs/types/styling
/// </remarks>
[DataContract]
public class Styling : IBaseModel
{
/// <summary>
/// See <code>link_color</code>
/// </summary>
[DataMember(Name = "link_color")]
public string LinkColor { get; set; }
/// <summary>
/// See <code>tag_background_color</code>
/// </summary>
[DataMember(Name = "tag_background_color")]
public string TagBackgroundColor { get; set; }
/// <summary>
/// See <code>tag_foreground_color</code>
/// </summary>
[DataMember(Name = "tag_foreground_color")]
public string TagForegroundColor { get; set; }
}
Using the API is super simple, which is how I wanted it. It's pretty much four lines of code (if you don't do inline construction) to send an anonymous request:
var config = new Configuration(); var handler = new Handler(config); var request = new SitesRequest(); var sites = handler.SubmitRequest(request);
To send a non-anonymous request with an application key, simply put the key in the config
object:
var config = new Configuration(); config.Key = "Some SE API Key."; var handler = new Handler(config); var request = new SitesRequest(); var sites = handler.SubmitRequest(request);
You can also compress things into fewer lines pretty easily.
var sites = new Handler(new Configuration { Key = "Some SE API Key." }).SubmitRequest(new SitesRequest());
Feel free to comment on anything and everything. I want to make sure I'm doing this in an understandable, modular and powerful way before I proceed.