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
ExtractRateAsyncwith the HTML value as its argument and pass its returnedResultto 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
strongelement, 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.GetRateAsyncreturns an error.BcvRates.GetUSDRateAsyncreturns 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
BcvRatesusing aNullLoggerinstance, 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
ShouldandBe, provided by theFluentAssertionspackage.
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!