I started a hobby project to find the books that I read (paperbacks) so that I can keep track of them (reading start date, finish date, number of pages, etc..)
The first challenge was to find the book on the net, and I realized there were different sources which you can search from, like google books (provides a JSON API), Amazon, isbnsearch.org (isbn search) etc.
I also want to cache the search results in my database so that I can search the books in my database first (to speed things up and not to exhaust API limits).
I then implemented something, and then abstracted something, then implemented and abstracted and ...
Finally, I came up with a generic search abstraction instead of something that searched for books only.
Any suggestions are welcome. I might be missing some pattern that covers all of this. I know this pattern is similar to the abstract factory, but maybe there are ways or patterns that I can use to simplify things more.
Here is the abstraction I finally came up with:
That is, for every different search source I need to implement 3 classes whose responsibilities are very well defined and very very simple. This is the most convenient way that I could achieve supporting modularity and testability.
Here is the source code for the interfaces:
public interface ISearchClient<SourceResponseType>
{
SourceResponseType ExecuteRemoteSearch(string query);
}
public interface ISearchResponseParser<SourceResponseType, SourceItemType>
{
IEnumerable<SourceItemType> ParseResponse(SourceResponseType searchResponse);
}
public interface ISearchItemConverter<SourceItemType, FinalItemType>
{
FinalItemType Convert(SourceItemType sourceItem);
}
And here is one functional abstract class that brings these 3 together:
public abstract class SearchProvider<SourceResponseType, SourceItemType, FinalItemType>
{
protected abstract ISearchClient<SourceResponseType> CreateClient();
protected abstract ISearchResponseParser<SourceResponseType, SourceItemType> CreateParser();
protected abstract ISearchItemConverter<SourceItemType, FinalItemType> CreateConverter();
public virtual IEnumerable<FinalItemType> Search(string query)
{
ISearchClient<SourceResponseType> searchClient = CreateClient();
SourceResponseType searchResponse = searchClient.ExecuteRemoteSearch(query);
ISearchResponseParser<SourceResponseType, SourceItemType> searchResponseParser = CreateParser();
IEnumerable<SourceItemType> searchSourceItems = searchResponseParser.ParseResponse(searchResponse);
ISearchItemConverter<SourceItemType, FinalItemType> searchItemConverter = CreateConverter();
List<FinalItemType> searchResults = new List<FinalItemType>();
foreach (SourceItemType sourceItem in searchSourceItems)
{
FinalItemType finalItem = searchItemConverter.Convert(sourceItem);
searchResults.Add(finalItem);
}
return searchResults;
}
}
Note: Paging is out of the scope right now
I also implemented some helper classes (Which I don't like to call helpers actually. I never liked the notion of helper classes with static methods, but these functional abstract classes are re-usable and can be used by the implementations so I call them helpers)
For Http Get Clients:
public abstract class HttpGetSearchClient : ISearchClient<string>
{
protected abstract string SearchUriTemplate { get; }
protected virtual WebClient GetHttpClient()
{
return new WebClient();
}
public virtual string ExecuteRemoteSearch(string query)
{
string urlEncodedQuery = HttpUtility.UrlEncode(query);
string formattedQueryUrl = string.Format(SearchUriTemplate, urlEncodedQuery);
return GetHttpClient().DownloadString(formattedQueryUrl);
}
}
For Parsing Http Get response using HtmlAgilityPack:
public abstract class HtmlResponseParser : ISearchResponseParser<string, HtmlNode>
{
protected abstract string SourceItemXPathQuery { get; }
public IEnumerable<HtmlNode> ParseResponse(string searchResponse)
{
HtmlAgilityPack.HtmlDocument htmlDocument = new HtmlAgilityPack.HtmlDocument();
htmlDocument.LoadHtml(searchResponse);
HtmlNodeCollection bookNodes = htmlDocument.DocumentNode.SelectNodes(SourceItemXPathQuery);
return bookNodes;
}
}
Then, I came up with this to cover an actual search provider so that the only thing a search provider (a provider which searched a specific source) needs is to implement 3 concrete classes, 1 SearchClient, 1 ResponseParser and 1 ItemConverter:
public abstract class SearchProvider<SourceResponseType, SourceItemType, FinalItemType, ClientType, ParserType, ConverterType>
: SearchProvider<SourceResponseType, SourceItemType, FinalItemType>
where ClientType : ISearchClient<SourceResponseType>, new()
where ParserType : ISearchResponseParser<SourceResponseType, SourceItemType>, new()
where ConverterType : ISearchItemConverter<SourceItemType, FinalItemType>, new()
{
protected override ISearchClient<SourceResponseType> CreateClient()
{
return new ClientType();
}
protected override ISearchResponseParser<SourceResponseType, SourceItemType> CreateParser()
{
return new ParserType();
}
protected override ISearchItemConverter<SourceItemType, FinalItemType> CreateConverter()
{
return new ConverterType();
}
}
And rest, is just FUN!
Here is a sample implementation for isbnsearch.org:
public class Book : Lab.Book
{
}
public class SearchClient : HttpGetSearchClient
{
protected override string SearchUriTemplate
{
get { return "http://www.isbnsearch.org/isbn/{0}"; }
}
}
public class ResponseParser : HtmlResponseParser
{
protected override string SourceItemXPathQuery
{
get { return "//div[@id='book']"; }
}
}
public class ItemConverter : ISearchItemConverter<HtmlNode, IBook>
{
public IBook Convert(HtmlNode sourceItem)
{
Book newBook = new Book();
HtmlNode thumbnailNode = sourceItem.SelectSingleNode("div[@class='thumbnail']");
if (thumbnailNode != null)
{
newBook.ThumbnailUri = thumbnailNode.SelectSingleNode("img").Attributes["src"].Value;
}
HtmlNode titleNode = sourceItem.SelectSingleNode("div[@class='bookinfo']/h2");
if (titleNode != null)
{
newBook.Title = titleNode.InnerText;
}
foreach (HtmlNode pStrongNode in sourceItem.SelectNodes("div[@class='bookinfo']/p/strong"))
{
if (pStrongNode.InnerText == "ISBN-13:")
{
newBook.ISBN13 = pStrongNode.ParentNode.SelectSingleNode("a").InnerText;
}
else if (pStrongNode.InnerText == "ISBN-10:")
{
newBook.ISBN10 = pStrongNode.ParentNode.SelectSingleNode("a").InnerText;
}
else if (pStrongNode.InnerText == "Author:")
{
newBook.Author = pStrongNode.ParentNode.InnerText.Replace("Author: ", "");
}
else if (pStrongNode.InnerText == "Publisher:")
{
newBook.Publisher = pStrongNode.ParentNode.InnerText.Replace("Publisher: ", "");
}
else if (pStrongNode.InnerText == "Published:")
{
newBook.PublishDate = pStrongNode.ParentNode.InnerText.Replace("Published: ", "");
}
}
return newBook;
}
}
And finally, isbnsearch.org search provider is completed by defining: (SearchClient, ResponseParser, ItemConverter are the classes implemented above, think of these as in a plug-in assembly, they have their own namespace)
public class SearchProvider : SearchProvider<string, HtmlNode, IBook, SearchClient, ResponseParser, ItemConverter>
{
}
And here is the IBook interface:
public interface IBook
{
Guid Id { get; set; }
string Title { get; set; }
string Description { get; set; }
string ThumbnailUri { get; set; }
string ISBN10 { get; set; }
string ISBN13 { get; set; }
string Author { get; set; }
string Publisher { get; set; }
string PublishDate { get; set; }
}
public class Book : IBook
{
public virtual Guid Id { get; set; }
public virtual string Title { get; set; }
public virtual string Description { get; set; }
public virtual string ThumbnailUri { get; set; }
public virtual string ISBN10 { get; set; }
public virtual string ISBN13 { get; set; }
public virtual string Author { get; set; }
public virtual string Publisher { get; set; }
public virtual string PublishDate { get; set; }
}