Welcome to part three of building and deploying a .NET 6 / Ract JS app into Azure.
One of my solemn rules of coding is to have no build time warnings. Sadly, the code I have committed so far has warnings aplenty. We’ll fix that now. Start with the LoginControl, update the following:
First, add a check for a null email address from the database. We know it should never be null, but the compiler doesn’t know this, and it could be null. Before the BCrypt check add the following.
if (string.IsNullOrWhiteSpace(user.email))
{
return BadRequest();
}
Then wrap the roles assignments in a null check.
if (user.roles != null)
{
foreach (var role in user.roles)
{
claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, role));
}
}
Next move to the unit test and we’ll clean up that code. It pains me to add an otherwise unnecessary instantiation of an object, but we can eliminate the warnings by doing so. If you have a less painful way to do this, drop me a comment below.
Update the declarations to not be nullable but initialized.
private LoginController target = new(new Mock<IUserData>().Object);
private Mock<IUserData> userMock = new();
Then update the following two tests to handle a possible null result.
[TestMethod]
public void PostReturnsUserInOkObject()
{
userMock.Setup(um => um.GetUser(It.IsAny<string>())).Returns(new User
{
password = hash,
email = "test@example.com",
roles = new[] { "admin" }
});
var credentials = new Credentials
{
email = "foo",
password = password
};
var result = target.Post(credentials) as OkObjectResult;
Assert.IsNotNull(result?.Value as User);
}
[TestMethod]
public void PostNullsHashFromReturnObject()
{
userMock.Setup(um => um.GetUser(It.IsAny<string>())).Returns(new User
{
password = hash,
email = "test@example.com",
roles = new[] { "admin" }
});
var credentials = new Credentials
{
email = "foo",
password = password
};
var result = target.Post(credentials) as OkObjectResult;
var user = result?.Value as User;
Assert.IsNull(user?.password);
}
With that out of the way, let’s do something interesting.
Like with the login, we will take a test first approach to building a blog controller. If you have Visual Studio 2022 Enterprise, start your live testing.
Navigate to the blog.ui.tests project and add a new class and name it BlogControllerTests.
[TestClass]
public class BlogControllerTests
{
private BlogController target = new(new Mock<IBlogData>().Object);
private Mock<IBlogData> blogMock = new();
}
Failure to compile is a valid first failing test. Let’s finish out the initialization code and first tests, then we will get some UI code written to get the test passing. For our first tests we will confirm that we can post a new blog to the database and get the id of the blog returned to us. Further, I want the server to timestamp the blog.
First, we will write the conditions that the title and article not be empty strings. I’m not leaving them null, as the engine will catch nulls for us, we need to check for non-null but empty.
[TestMethod]
public void PostFailsOnEmptyArticle()
{
var blog = new BlogEntry
{
title = "foo",
article = string.Empty
};
var result = target.Post(blog) as BadRequestResult;
Assert.IsNotNull(result);
}
[TestMethod]
public void PostFailsOnEmptyTitle()
{
var blog = new BlogEntry
{
title = string.Empty,
article = "foo",
};
var result = target.Post(blog) as BadRequestResult;
Assert.IsNotNull(result);
}
Next, we verify that we get an OK status if the blog is valid.
[TestMethod]
public void PostReturnsOkObjectResultForValidBlog()
{
var blog = new BlogEntry
{
title = "foo",
article = "<h1>heh</h1>"
};
var result = target.Post(blog) as OkObjectResult;
Assert.IsNotNull(result);
}
Now, we verify that the controller actually passes the blog to the DAL.
[TestMethod]
public void PostPassesBlogToDAL()
{
var blog = new BlogEntry
{
title = "foo",
article = "<h1>heh</h1>"
};
target.Post(blog);
blogMock.Verify(b => b == blog, Times.Once);
}
Finally, we want to insure we are returning the newly established identity for the blog.
[TestMethod]
public void PostReturnsNewId()
{
var expected = Guid.NewGuid().ToString();
var blog = new BlogEntry
{
title = "foo",
article = "<h1>heh</h1>"
};
blogMock.Setup(x => x.Add(It.IsAny<BlogEntry>())).Returns(expected);
var result = ((target.Post(blog) as OkObjectResult)?.Value as IdResponse)?.id;
Assert.AreEqual(expected, result);
}
Now let’s get some tests passing. We’ll start with defining the BlogEntry record. Navigate to the blog.objects project and add a new class. Call it BlogEntry.
public record BlogEntry
{
public string title { get; init; } = string.Empty;
public string article { get; init; } = string.Empty;
}
Go back to our test class and add the reference to blog.objects.
Now we need a DAL interface. Navigate to blog.objecits/Interfaces and add a new interface IBlogData.
public interface IBlogData
{
string Add(BlogEntry entry);
}
Time to build the controller. Navigate to blog.ui/Controllers and add an empty API controller called BlogController.
[Route("api/[controller]")]
[ApiController]
public class BlogController : ControllerBase
{
private IBlogData blogData;
public BlogController(IBlogData data)
{
blogData = data;
}
[HttpPost]
public IActionResult Post([FromBody] BlogEntry entry)
{
throw new NotImplementedException();
}
}
Then we add the IdResponse to the blog.objects.
public record IdResponse
{
public string id { get; init; } = string.Empty;
}
At this point, if you are not using live unit testing, run your tests. Either way, they should be all failing, but the program is compiling and running. Let’s get our tests passing.
[HttpPost]
public IActionResult Post([FromBody] BlogEntry entry)
{
if (entry == null ||
string.IsNullOrWhiteSpace(entry.title) ||
string.IsNullOrWhiteSpace(entry.article))
{
return BadRequest();
}
throw new NotImplementedException();
}
That fixes our first two tests. Let’s get the last three passing.
[HttpPost]
public IActionResult Post([FromBody] BlogEntry entry)
{
if (entry == null ||
string.IsNullOrWhiteSpace(entry.title) ||
string.IsNullOrWhiteSpace(entry.article))
{
return BadRequest();
}
var result = blogData.Add(entry);
return Ok(new IdResponse { id = result });
}
This got two of the three tests passing. Let’s find out what is going on.
Looks like I wrote the test wrong, my bad. Let’s fix it.
[TestMethod]
public void PostPassesBlogToDAL()
{
var blog = new BlogEntry
{
title = "foo",
article = "<h1>heh</h1>"
};
target.Post(blog);
blogMock.Verify(b => b.Add(It.Is<BlogEntry>(be => be.Equals(blog))), Times.Once);
}
Now all our tests are passing. We will now implement the storage of the entry objects. Create a new class in blog.data and name it BlogData. Now implement the interface.
public class BlogData : IBlogData
{
public string Add(BlogEntry entry)
{
throw new NotImplementedException();
}
}
Let’s get some code in there. Our UserData class will help us, as it is similar. My bones are aching, Ani, there’s a refactoring coming. But until then, copy pasta.
public class BlogData : IBlogData
{
private IConfiguration configuration;
private string databaseId;
private string key;
private string uri;
private readonly string collection = "blogs";
public BlogData(IConfiguration config)
{
configuration = config;
databaseId = configuration.GetSection("Database").GetSection("DatabaseId").Value;
key = configuration.GetSection("Database").GetSection("Key").Value;
uri = configuration.GetSection("Database").GetSection("Endpoint").Value;
}
public string Add(BlogEntry entry)
{
throw new NotImplementedException();
}
}
At this point, I diverge. We want blog ids that are URL friendly, not GUIDs. So let’s spin up a service to generate some random ids that are acceptable for URLs.
In the data project, add a folder called Services. We’ll put our ID generator there. Create a new class call ShortId. The following should have enough entropy to not crash when inserting new blogs.
internal static class ShortId
{
private static char[] values = { '0', '1', '2', '3', '4', '5', '6', '7', '8','9', 'a', 'b',
'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
't', 'u', 'v', 'w', 'x', 'y', 'z', '-' };
public static string NewId()
{
using (var rand = RandomNumberGenerator.Create())
{
var bytes = new byte[4];
rand.GetBytes(bytes);
var value = BitConverter.ToInt32(bytes, 0);
var random = new Random(value);
var result = new StringBuilder();
while (result.Length < 12)
{
result.Append(values[random.Next(values.Length)]);
}
return result.ToString();
}
}
}
Feel free to modify the input array to meet your needs.
Navigate to blog.objects and create a new class called Blog. This will be the object we persist to Cosmos DB and also return to the UI later when it requests blogs.
public class Blog
{
public string? id { get; set; }
public string? title { get; set; }
public string? article { get; set; }
public DateTime publishedAt { get; set; }
}
We want to copy a BlogEntry to a Blog and verify that the core items come across. Let’s create an extension method to copy the data and test it. First, add a new test project called blog.objects.tests. Then add a new test class call BlogExtensionsTests.
[TestClass]
public class BlogExtensionTests
{
[TestMethod]
public void ToBlogSetsTitle()
{
var expected = "foo";
var target = new BlogEntry
{
title = expected
};
var result = target.ToBlog();
Assert.AreEqual(expected, result.title);
}
[TestMethod]
public void ToBlogSetsArticle()
{
var expected = "foo";
var target = new BlogEntry
{
article = expected
};
var result = target.ToBlog();
Assert.AreEqual(expected, result.article);
}
}
Let’s create the extension and get the code compiling and passing. In the blog.objects project, add a folder called Extensions. Then add a class call BlogEntryExtensions.
public static class BlogEntryExtensions
{
public static Blog ToBlog(this BlogEntry source)
{
return new Blog
{
title = source.title,
article = source.article
};
}
}
At this point, all our test should be passing. Now we can finish the DAL logic using our extension method id generator.
public string Add(BlogEntry entry)
{
var blog = entry.ToBlog();
blog.publishedAt = DateTime.UtcNow;
blog.id = ShortId.NewId();
using (var client = new CosmosClient(uri, key))
{
var container = client.GetContainer(databaseId, collection);
container.CreateItemAsync(blog, new PartitionKey(blog.id)).Wait();
}
return blog.id;
}
I’ll not belabor anything here, as we already covered using Cosmos DB in the first post in this series. Let’s get the read and delete operations tested and written.
[TestMethod]
public void GetReturnsOkObjectResultOnSuccess()
{
blogMock.Setup(b => b.GetTitles()).Returns(new List<Title> { new Title { title = "foo" } });
var result = target.Get() as OkObjectResult;
Assert.IsNotNull(result);
}
[TestMethod]
public void GetReturnsNotFoundOnEmptyResult()
{
blogMock.Setup(b => b.GetTitles()).Returns(new List<Title>());
var result = target.Get() as NotFoundResult;
Assert.IsNotNull(result);
}
[TestMethod]
public void GetReturnsNotFoundOnNullResult()
{
blogMock.Setup(b => b.GetTitles()).Returns<List<Title>>(null);
var result = target.Get() as NotFoundResult;
Assert.IsNotNull(result);
}
As you can see in the test, I am expecting a new object type to be returned for a GetAll(). This is because I don’t want to return every blog’s content when I am getting all. I want to build a list of title for the user to see and click on. Let’s get the test passing. First the Title record in blog.objects.
public record Title
{
public string id { get; init; } = string.Empty;
public string title { get; init; } = string.Empty;
public DateTime publishedAt { get; init; } = DateTime.MinValue;
}
Then the BlogData interface and implementation.
public interface IBlogData
{
string Add(BlogEntry entry);
IList<Title> GetTitles();
}
public IList<Title> GetTitles()
{
throw new NotImplementedException();
}
And finally, the controller.
[HttpGet]
public IActionResult Get()
{
throw new NotImplementedException();
}
We should now have three failing tests. Let’s code the controller.
[HttpGet]
public IActionResult Get()
{
var result = blogData.GetTitles();
if (result == null || result.Count == 0)
{
return NotFound();
}
return Ok(result);
}
At this point, all tests ought to be passing and we are ready to fill in the BlogData method. Navigate to the class and update the method to get all blogs into a Title object.
public IList<Title> GetTitles()
{
using (var client = new CosmosClient(uri, key))
{
var container = client.GetContainer(databaseId, collection);
var response = new List<Title>();
var query = container.GetItemQueryIterator<Title>();
while (query.HasMoreResults)
{
var result = query.ReadNextAsync().Result;
response.AddRange(result.ToList());
}
return response;
}
}
Now let’s get a single blog, with tests.
[TestMethod]
public void GetIdReturnsOkObjectResultOnSuccess()
{
blogMock.Setup(b => b.Get(It.IsAny<string>())).Returns(new Blog());
var result = target.Get("foo") as OkObjectResult;
Assert.IsNotNull(result);
}
[TestMethod]
public void GetIsReturnsNotFoundOnNullResult()
{
blogMock.Setup(b => b.Get(It.IsAny<string>())).Returns<Blog>(null);
var result = target.Get("foo") as NotFoundResult;
Assert.IsNotNull(result);
}
And the controller code.
[HttpGet("{id}")]
public IActionResult Get([FromQuery] string id)
{
var result = blogData.Get(id);
if ( result == null)
{
return NotFound();
}
return Ok(result);
}
Now let’s fill in the blog data method. We will be asking for the ‘latest’ blog from the UI and I have decided to implement that as a special case ID for the blog. If the incoming id is ‘latest’ we will pull the latest blog, otherwise we will pull the exact blog ID.
To do this, we need to fix an error in the IBogData interface. Get() should be nullable.
public interface IBlogData
{
string Add(BlogEntry entry);
IList<Title> GetTitles();
Blog? Get(string id);
}
And the BlogData implementation.
public Blog? Get(string id)
{
using (var client = new CosmosClient(uri, key))
{
var container = client.GetContainer(databaseId, collection);
if (id == "latest")
{
var query = new QueryDefinition("select top 1 * from blogs b order by b.publishedAt desc");
using (var feed = container.GetItemQueryIterator<Blog>(query))
{
while (feed.HasMoreResults)
{
return feed.ReadNextAsync().Result?.FirstOrDefault();
}
}
}
else
{
var response = container.ReadItemAsync<Blog>(id, new PartitionKey(id)).Result;
return response;
}
}
return null;
}
Since we don’t want just anyone posting blogs, let’s add some security to the controller. To the Post() method, add the following decorator.
[Authorize(Policy = "RequireAdminRole")]
In my prior articles, we configured that role, so this will be enough to keep random people or bots from creating new blogs.
We didn’t go much past what we have previously learned in this blog. In the next article we will add the user interface to posting a new blog, including uploading blog images to the Azure CDN. On to the new!
As always, the code for this project is found here.
Drop me some comments below.
Thank you for reading.