Providing the USD/VES Rate, using C# Functional Extensions
By Pedro R. Borges
As part of dobs, a C# CLI USD-VES converter, I wrote a library that provides the official exchange rate from the US dollar to Venezuelan Bolivars (VES). I developed the dobs app mainly for personal use and to practice building .NET applications in C#. I also took the opportunity to explore the CSharpFunctionalExtensions library. In this post, I describe this Rate Provider library, which can serve as an example use of CSharp Functional Extensions.
The library scrapes the exchange rate from the website of the Central Bank of Venezuela (BCV) and uses Angle Sharp to extract the rate from the page content.
To represent the rate I define a generic type Rate<TFrom, TTo>
, and USD
and VES
classes so that the provided rate is of type Rate<USD, VES
.
Table Of Contents
The Rate Provider library
The library is available in the RateProvider
folder on the dobs GitHub repo and is organized as a project described by the RateProvider.csproj
file.
The folder tree structure is as follows, where, as usual, folders correspond to namespaces, and each .cs file contains its homonymous class:
RateProvider/
├── BcvRates.cs
├── BcvRatesLogger.cs
├── BcvScraper
│ ├── HtmlLoader.cs
│ └── Scraper.cs
├── RateProvider.csproj
└── Types
├── Currency.cs
└── Rate.cs
The entry point for the scraper is the BcvRates.GetUSDRateAsync
task, which gets the current rate to convert from USD to VES.
Let’s start with the types of the result.
Typed exchange rates and currencies
The Rate class
An exchange rate is an amount used, either as a multiplier or a divisor, to convert between two currencies at a given time.
I decided to make the currencies part of the type of the exchange rate, as its source currency TFrom
, and target currency TTo
, with the amount as a multiplier to convert TFrom
to TTo
.
Since the rates we are dealing with are fixed for a calendar day, I use a DateOnly
for the time associated with each rate.
This gives the following Rate
(record) class:
public record Rate<TFrom, TTo>
where TFrom : Currency
where TTo : Currency
{
public decimal Multiplier { get; init; }
public DateOnly Date { get; init; }
}
The currency types in the rate give extra safety for the conversion methods in the Currency
class.
The Rate
class provides methods for fixing the precision (i.e. number of decimals) for the multiplier, and to find out if a rate is newer than another.
The IsNewerThan
method just makes sure that the argument is not null and compares the dates of each rate:
public bool IsNewerThan(Rate<TFrom, TTo> other)
{
ArgumentNullException.ThrowIfNull(other);
return this.Date > other.Date;
}
}
These methods are used by the dobs app, which also requires JSON serialization.
This gives us the following Rate.cs
, with comments omitted:
using System.Text.Json.Serialization;
namespace RateProvider.Types;
public record Rate<TFrom, TTo>
where TFrom : Currency
where TTo : Currency
{
[JsonRequired]
public decimal Multiplier { get; init; }
[JsonRequired]
public DateOnly Date { get; init; }
public Rate(decimal multiplier, DateOnly date) =>
(this.Multiplier, this.Date) = (multiplier, date);
public Rate<TFrom, TTo> WithPrecision(int precision) =>
this with
{
Multiplier = decimal.Round(this.Multiplier, precision)
};
public bool IsNewerThan(Rate<TFrom, TTo> other)
{
ArgumentNullException.ThrowIfNull(other);
return this.Date > other.Date;
}
}
Currencies
The app deals with two currencies: USD and VES, which are represented by concrete subclasses of the abstract Currency
class.
A currency is represented as an amount and a prefix (for display purposes):
public abstract record Currency
{
protected abstract string Prefix { get; }
public decimal Amount { get; init; }
// ...
}
This class overrides ToString()
to use Prefix
, and provides a method to set the number of decimals of the amount, as well as methods to convert to another currency.
To convert to a TTo
currency, we may have a rate with target TTo
, in which case the rate amount acts as a multiplier:
public TTo Convert<TFrom, TTo>(Rate<TFrom, TTo> rate)
where TFrom : Currency
where TTo : Currency, new()
{
ArgumentNullException.ThrowIfNull(rate);
return new TTo() with { Amount = this.Amount * rate.Multiplier };
}
However, if the rate for the conversion has TTo
as its source, the rate multiplier becomes a divisor:
public TTo Convert<TFrom, TTo>(Rate<TTo, TFrom> rate)
where TFrom : Currency
where TTo : Currency, new()
{
ArgumentNullException.ThrowIfNull(rate);
return new TTo() with { Amount = this.Amount / rate.Multiplier };
}
Although exchange rates should not be zero, they could become 0 when their number of decimals is set;
therefore, conversions with “inverse” rates could throw DivideByZeroException
.
Note that since Convert<TFrom, TTo>
creates a new TTo
instance, a new constraint is imposed on TTo
.
To satisfy this constraint, the concrete currencies must have a parameterless constructor, and so I provide one in the abstract class to be used by its subclasses.
We have, then, the following definition for Currency
public abstract record Currency
{
protected abstract string Prefix { get; }
public decimal Amount { get; init; }
protected Currency() => Amount = default;
public Currency WithDecimals(int numberOfDecimals)
=> this with { Amount = decimal.Round(this.Amount, numberOfDecimals) };
public override string ToString() => $"{this.Prefix} {this.Amount}";
public TTo Convert<TFrom, TTo>(Rate<TFrom, TTo> rate)
where TFrom : Currency
where TTo : Currency, new()
{
ArgumentNullException.ThrowIfNull(rate);
return new TTo() with { Amount = this.Amount * rate.Multiplier };
}
public TTo Convert<TFrom, TTo>(Rate<TTo, TFrom> rate)
where TFrom : Currency
where TTo : Currency, new()
{
ArgumentNullException.ThrowIfNull(rate);
return new TTo() with { Amount = this.Amount / rate.Multiplier };
}
}
I define two concrete currency subclasses: USD
and VES
.
They differ only in their prefix
: “US$” and “Bs.” for USD
and VES
respectively.
Their definition is quite straightforward, and I include only USD
here:
public record Usd : Currency
{
protected override string Prefix => "US$";
public Usd()
: base() { }
public Usd(decimal amount) => Amount = amount;
public override string ToString() => base.ToString();
}
The GetUSDRateAsync
task
As mentioned above, the Rate Provider library offers the GetUSDRateAsync
task to retrieve the USD to VES exchange rate, as a member of the BcvRates
class.
The task receives the URI of the page to scrape as an argument and makes use of two tasks defined in the RateProvider.BcvScraper namespace
:
HtmlLoader.GetUriContentAsync(Uri uri)
, which returns the HTML content of the specified URI as a string if the operation is successful, or returns an error message if it fails.Scraper.ExtractRateAsync(string html)
, which returns the rate extracted from the provided HTML content if successful, or returns an error message if the extraction fails. The extracted rate is of typeRate<USD, VES>
.
The dual return types of these tasks are modeled by the Result
type provided by the CSharpFunctionalExtensions library, giving the following declarations:
async Task<Result<string>> GetUriContentAsync(Uri uri) {...}
async Task<Result<Rate<Usd, Ves>>> ExtractRateAsync(string html) {...}
A value of type Result
is either a success value or an error value —of type string in our case.
Starting with GetUriContentAsync
, the GetUSDRateAsync
performs a sequence of steps, each receiving a Result
from the previous step and producing a Result
for the next one, as follows:
GetUriContentAsync(bcvUri)
produces either a successful (HTML) value or an error message.- If the received value is an error message, log it, or do nothing otherwise. In any case, pass the value received to the next step.
- If the received value is an error, do nothing and pass it along.
If it is a successful HTML, invoke
ExtractRateAsync
with the HTML value as its argument and pass its returnedResult
to the next step. - If the received value is an error message, log it, or do nothing otherwise. In any case, pass the value received to the next step.
This is a sequence of monadic operations, also called “Railway oriented programming”.
The CSharpFunctionalExtensions library provides methods to perform operations like these, and many others, on the Result
type.
Steps 2 and 4 are implemented by the TapError
method, which applies a function to the error message it receives if that is the case, and passes whatever value it receives to the next operation.
Error messages are logged with several methods provided by the RateProviderLogger
class defined using the LoggerMessageAttribute and accessed through an ILogger received by the BcvRates
class.
I do not include this class in this post, it is available in the BcvRatesLogger.cs file in GitHub.
Step 2, for example, is performed by TapError(this._logger.FailedReadingBCVPage)
, where this.logger
is the local reference to the ILogger instance received by BcvRates
.
Step 3 is performed by Bind(Scraper.ExtractRateAsync)
.
It applies ExtractRateAsync
to the HTML received if that is the case and propagates any error to the next step.
The error could come from the previous step or be produced by ExtractRateAsync
.
The whole sequence is then performed by:
HtmlLoader.GetUriContentAsync(bcvUri)
.TapError(this._logger.FailedReadingBCVPage)
.Bind(Scraper.ExtractRateAsync)
.TapError(_logger.CouldNotGetRateFromHtml);
This command sequence creates a task that returns a Result<Rate<Usd, Ves>>
when awaited.
If the result contains an error message it is discarded since it has been logged already, and we are left either with a rate or nothing.
This is modeled using the Maybe
constructor provided by CSharpFunctionalExtensions as the type Maybe<Rate<Usd, Ves>>
.
We arrive then at the following listing for BcvRates.cs
, with comments omitted:
using Microsoft.Extensions.Logging;
using CSharpFunctionalExtensions;
using RateProvider.BcvScraper;
using RateProvider.Types;
namespace RateProvider;
public class BcvRates(ILogger logger)
{
private readonly ILogger _logger = logger;
public async Task<Maybe<Rate<Usd, Ves>>> GetUSDRateAsync(Uri bcvUri)
{
_logger.GettingRate();
var rRateTask = HtmlLoader
.GetUriContentAsync(bcvUri)
.TapError(this._logger.FailedReadingBCVPage)
.Bind(Scraper.ExtractRateAsync)
.TapError(this._logger.CouldNotGetRateFromHtml);
var rRate = await rRateTask.ConfigureAwait(false);
return rRate.IsSuccess ? Maybe.From(rRate.Value) : Maybe.None;
}
}
I now describe the two remaining tasks: loading the HTML from the BCV website and extracting the rate from it, both defined in the RateProvider.BcvScraper
namespace.
Loading the HTML
The GetUriContentAsync
task is straightforward.
It fetches the content of the given URI as a string using HttpClient.GetStringAsync
and returns it as a successful Result
value or, if an exception occurs, returns a message as an error Result
value:
using CSharpFunctionalExtensions;
namespace RateProvider.BcvScraper;
internal static class HtmlLoader
{
public static async Task<Result<string>> GetUriContentAsync(Uri uri)
{
try
{
using (var client = new HttpClient())
{
var responseBody = await client.GetStringAsync(uri).ConfigureAwait(false);
return Result.Success(responseBody);
}
}
catch (Exception ex)
when (ex is HttpRequestException or TaskCanceledException or InvalidOperationException)
{
return Result.Failure<string>($"Problem loading {uri}: {ex.Message}");
}
}
}
Extracting the exchange rate
To extract the exchange rate using AngleSharp we must create a browsing context and parse the HTML content with the OpenAsync
task.
We create a browsing context using the default configuration:
var context = BrowsingContext.New(Configuration.Default);
The OpenAsync
task receives a callback to request the HTML.
Since we already have it, we simply set it as the response of the request callback with the Content
method:
var document = await context.OpenAsync(req => req.Content(html)).ConfigureAwait(false);
We now have the HTML parsed into a DOM in the document
variable, whose type is IDocument
.
From there, we can use AngleSharp’s methods to navigate and query HTML elements (of type IElement
) to get the multiplier and date of the exchange rate.
Getting the multiplier
Our first step is to find the div element that contains the multiplier, whose ID is “dolar”.
For this, we use the QuerySelector
method on the document, which returns a nullable IElement
.
If the element is not found we return an error value:
var dolarDiv = document.QuerySelector("#dolar");
if (dolarDiv is null)
{
return Result.Failure<Rate<Usd, Ves>>("No dolar div");
}
This div contains the value of 1 US dollar in Bolivars, that is, the multiplier of the Rate<Usd, Ves>
, inside a strong
element.
We get this element with QuerySelector
and convert its returned value to a Result<IElement>
with the ToResult
method from CSharpFunctionalExtensions.
If the IElement was found it is converted to a successful Result
value, and a null is converted to an error value with the message passed to ToResult
.
We then need to transform the text in the strong
element into a decimal, giving us a Result<decimal>
.
To do this we use the MapTry
method of the Result
type, which receives two functions:
- A function to apply to the input value if it is a success.
In this case, a function to obtain a decimal from the
strong
element, which we do by parsing the text content of the element taking into account that the decimal number follows the Venezuelan culture. - An error handler, that is, a function to produce an error message from the exception produced by the first function, if there is one.
This translates to the code:
var rMultiplier = dolarDiv
.QuerySelector("strong")
.ToResult("No strong element")
.MapTry(
strongElement =>
decimal.Parse(strongElement.TextContent.Trim(), new CultureInfo("es-VE")),
_ => "Problem parsing usd multiplier"
);
Getting the date
If no errors occurred while obtaining the multiplier we carry on to get the rate’s date, which is in the div element that follows dolarDiv
.
This element is found with the NextElementSibling
method and converted to a Result<IElement>
with the ToResult
method, which we have used before.
dolarDiv.NextElementSibling.ToResult("No sibling for dolarDiv")
As should be familiar by now, we continue with several operations on the Result
type, provided by the Functional extensions library, until we obtain the desired date.
First, we use the Bind
method, which we used in GetUSDRateAsync
, to find the span
with the date within the div, and propagate any error.
The span
is of the form:
<span class="date-display-single" property="dc:date" datatype="xsd:dateTime"
content="2023-11-17T00:00:00-04:00">Viernes, 17 Noviembre 2023</span>
We get this element using QuerySelector
to find the span
by the value of the datatype
attribute, and we convert it to a Result
with ToResult
:
Bind(div => div.QuerySelector("span[datatype='xsd:dateTime']").ToResult("No date found"))
Then we parse the value of the content
attribute to a DateTime
; since this could generate an exception, we use MapTry
with an error handler, as we did to parse the multiplier:
MapTry(
span =>
DateTime.Parse(span.GetAttribute("content")!, CultureInfo.InvariantCulture),
_ => "No date or date in bad format"
)
Finally, we obtain the date component of the DateTime
, since we are not interested in the time component.
This operation does not generate any exception, so we use Map
instead of MapTry
:
Map(DateOnly.FromDateTime);
The complete sequence to produce a Result<DateOnly>
is then:
var rDate = dolarDiv
.NextElementSibling.ToResult("No sibling for dolarDiv")
.Bind(div =>
div.QuerySelector("span[datatype='xsd:dateTime']").ToResult("No date found")
)
.MapTry(
span =>
DateTime.Parse(span.GetAttribute("content")!, CultureInfo.InvariantCulture),
_ => "No date or date in bad format"
)
.Map(DateOnly.FromDateTime);
The final step on the Scraper.ExtractRateAsync
task is to return the exchange rate if the date was found, or the error message otherwise, as in the listing below.
The Scraper class
The complete file containing the Scraper
class, with comments omitted, is the following:
using System.Globalization;
using AngleSharp;
using AngleSharp.Dom;
using CSharpFunctionalExtensions;
using RateProvider.Types;
namespace RateProvider.BcvScraper;
internal static class Scraper
{
public static async Task<Result<Rate<Usd, Ves>>> ExtractRateAsync(string html)
{
var context = BrowsingContext.New(Configuration.Default);
var document = await context.OpenAsync(req => req.Content(html)).ConfigureAwait(false);
var dolarDiv = document.QuerySelector("#dolar");
if (dolarDiv is null)
{
return Result.Failure<Rate<Usd, Ves>>("No dolar div");
}
var rMultiplier = dolarDiv
.QuerySelector("strong")
.ToResult("No strong element")
.MapTry(
strongElement =>
decimal.Parse(strongElement.TextContent.Trim(), new CultureInfo("es-VE")),
_ => "Problem parsing usd multiplier"
);
if (rMultiplier.IsFailure)
{
return Result.Failure<Rate<Usd, Ves>>(rMultiplier.Error);
}
var rDate = dolarDiv
.NextElementSibling.ToResult("No sibling for dolarDiv")
.Bind(div =>
div.QuerySelector("span[datatype='xsd:dateTime']").ToResult("No date found")
)
.MapTry(
span =>
DateTime.Parse(span.GetAttribute("content")!, CultureInfo.InvariantCulture),
_ => "No date or date in bad format"
)
.Map(DateOnly.FromDateTime);
return rDate.IsSuccess
? Result.Success(new Rate<Usd, Ves>(rMultiplier.Value, rDate.Value))
: Result.Failure<Rate<Usd, Ves>>(rDate.Error);
}
}
Testing the rate provider
There are two xUnit test projects in the dobs app: RateProviderTests
for the Rate Provider library, and DobsTests
for the whole app.
The Rate Provider tests are in the tests/RateProviderTests
folder, with the following contents:
tests/RateProviderTests/
├── BcvRates.Tests.cs
├── BcvScraperTests
│ ├── HtmlLoader.Tests.cs
│ └── Scraper.Tests.cs
├── GlobalUsings.cs
└── RateProviderTests.csproj
I only include in this article one of the tests in BcvRates.Tests
, namely GetBcvRateAgreesWithExchangeDynRate
, to check if the exchange rate fetched by GetUSDRateAsync
agrees with The one retrieved from a request to the ExchangeDyn API at https://api.exchangedyn.com/markets/quotes/usdves/bcv.
Getting the ExchangeDyn rate
The request to the ExchangeDyn API is made by the GetRateAsync
task of the ExchangeDyn
class, which is part of the Utils
project, in the tests/Utils
folder.
The API response is a JSON string from which we are interested in:
- The USD/VES rate (or multiplier).
- The day the rate was retrieved.
- The time at which the rate was retrieved.
With these elements, we form a 3-tuple that represents the retrieved rate,
and we define the DynRate
type alias:
using DynRate = (decimal usdRate, DateOnly date, TimeOnly time);
With this alias, the type of GetRateAsync
is Task<Result<DynRate>>
.
To get the rate, we start by fetching the JSON from the API with the GetJsonContentAsync
task:
private static async Task<Result<string>> GetJsonContentAsync(HttpClient client) {...}
I do not include the code for this task here, it is almost identical to HtmlLoader.GetUriContentAsync
We now apply several operations from System.Text.Json
on the successful value or propagate any error, as usual, with the methods for the Result
type.
First, we parse the response as JsonDocument
, and then we get the “BCV” within the “sources” property:
var rBcvElement = await GetJsonContentAsync(client)
.MapTry(json => JsonDocument.Parse(json))
.MapTry(doc => doc.RootElement.GetProperty("sources").GetProperty("BCV"))
.ConfigureAwait(false);
When we used MapTry
before, we used an error handler as its second argument, to be applied to the exception generated by the first argument if there was one.
In this one-argument overload, MapTry
uses a default error handler that returns the error message of the exception as the error value of the Result
.
If there were no errors, we take the element out of the Result
for convenience:
var bcvElement = rBcvElement.Value;
Now we obtain the “last_retrieved” property of this element as a DateTimeOffset and get it as a DateTime in UTC:
var rRateDateTime = Result.Try(
() => bcvElement.GetProperty("last_retrieved").GetDateTimeOffset().UtcDateTime
);
Here we use Result.Try
instead of a Try-catch
block as a handy way of getting the result of the operation or catching the error message in a Result
type.
If no errors occurred, we get the “quote” property of BcvElement
as a string and parse it to obtain the USD rate:
var rUsdRate = Result
.Try(() => bcvElement.GetProperty("quote").ToString())
.MapTry(str => decimal.Parse(str, CultureInfo.InvariantCulture));
Finally, after checking for possible errors, we deconstruct the Daytime into its components and assemble the triplet to return. This gives the following complete task:
public static async Task<Result<DynRate>> GetRateAsync(HttpClient client)
{
ArgumentNullException.ThrowIfNull(client);
var rBcvElement = await GetJsonContentAsync(client)
.MapTry(json => JsonDocument.Parse(json))
.MapTry(doc => doc.RootElement.GetProperty("sources").GetProperty("BCV"))
.ConfigureAwait(false);
if (rBcvElement.IsFailure)
{
return Result.Failure<DynRate>(
$"Problem fetching ExchangeDyn rate: {rBcvElement.Error}"
);
}
var bcvElement = rBcvElement.Value;
var rRateDateTime = Result.Try(
() => bcvElement.GetProperty("last_retrieved").GetDateTimeOffset().UtcDateTime
);
if (rRateDateTime.IsFailure)
{
return Result.Failure<DynRate>("Bad or no datetime in ExchangeDyn response.");
}
var rUsdRate = Result
.Try(() => bcvElement.GetProperty("quote").ToString())
.MapTry(str => decimal.Parse(str, CultureInfo.InvariantCulture));
if (rUsdRate.IsFailure)
{
return Result.Failure<DynRate>(
"No quote or quote in bad format in ExchangeDyn response"
);
}
rRateDateTime.Value.Deconstruct(out var date, out var time);
return Result.Success((rUsdRate.Value, date, time));
}
Comparing the rates
To avoid false positives in the rate comparison test, we skip it if:
ExchangeDyn.GetRateAsync
returns an error.BcvRates.GetUSDRateAsync
returns an error.- The rates are not in sync and therefore can not be compared.
For this purpose, we use the XUnit Skippable Fact package, which provides the SkippableFact
attribute, a SkipException
that can be thrown directly or with several methods, like Skip.If
.
How can the rates be out of sync?
On working days after 17:30 UTC, the BCV publishes the rate for the following working day.
So, it can happen that when the test runs, BcvRates.GetUSDRateAsync
gets a new rate that has not been picked up yet by ExchangeDyn since it retrieves the rate every couple of hours.
In this case, we have:
- The BCV rate’s date is later than the ExchangeDyn rate: the date of the first will be the next working day while the second rate will be the date it was retrieved.
- The current day is a working day.
- The ExchangeDyn rate was retrieved before 17:30 UTC.
Using these conditions, we define the following methods:
private static bool DynRateIsBehind(
(decimal usdRate, DateOnly date, TimeOnly time) dynRate,
Rate<Usd, Ves> bcvRate
) =>
bcvRate.Date > dynRate.date
&& IsDayOfWeek(dynRate.date)
&& dynRate.time < TestData.RateChangeTimeUtc;
private static bool IsDayOfWeek(DateOnly date) =>
date.DayOfWeek is >= DayOfWeek.Monday and <= DayOfWeek.Friday;
Here, TestData
is a class in the Utils
project that provides static data, including RateChangeTimeUtc
, whose value is 17:30 UTC.
Note that we check if the test runs on a weekday instead of checking for a working day since this would require knowing the Venezuelan bank holidays for each year. Therefore, we could unnecessarily skip the test on some days.
Now we are ready to define the GetBcvRateAgreesWithExchangeDynRate
test, which performs the following steps:
- Fetch the ExchangeDyn rate.
- Skip the test if there are any errors.
- Create an instance of
BcvRates
using aNullLogger
instance, provided byMicrosoft.Extensions.Logging.Abstractions
. - Fetch the BCV rate with
GetUSDRateAsync
. - Skip the test if any error occurs.
- Skip the test if the rates are out of sync, checked by
DynRateIsBehind
. - Compare the two rates using
Should
andBe
, provided by theFluentAssertions
package.
The complete test is:
[SkippableFact]
public async Task GetBcvRateAgreesWithExchangeDynRate()
{
var rDynRate = await ExchangeDyn
.GetRateAsync(this._clientFixture.Client)
.ConfigureAwait(false);
if (rDynRate.IsFailure)
{
throw new SkipException($"Could not get ExchangeDyn rate: {rDynRate.Error}");
}
var dynRate = rDynRate.Value;
var bcvRates = new BcvRates(NullLogger.Instance);
var mRate = await bcvRates.GetUSDRateAsync(TestData.BcvUri).ConfigureAwait(false);
Skip.If(mRate.HasNoValue, "Could not get USD rate from BCV");
var bcvRate = mRate.Value;
Skip.If(DynRateIsBehind(dynRate, bcvRate), "ExchangeDyn rate has nott been updated yet.");
bcvRate.Multiplier.Should().Be(dynRate.usdRate);
}
Final comments
The rate and currency classes in this library were defined according to the needs of the Dobs app.
They can be extended with more structure and functionality, or modified, to be used in other projects.
The prefix
in the currencies, for example, can be renamed to “name”.
Even in our context, it should perhaps be named “displayPrefix”.
Given that I am especially fond of Functional Programming, it is no surprise that I liked using CSharp Functional Extensions.
However, I think that most readers would agree that the Maybe
and Result
and the extensions methods used in this post make the code more concise and legible.
If interested, I encourage you to have a look at the library, which offers a lot more methods than those I used here.
Finally, as always: I would appreciate any feedback!