Skip to content

Network layer and test projects - #12

Open
jackschonherr wants to merge 6 commits into
mainfrom
network-access-layer
Open

Network layer and test projects#12
jackschonherr wants to merge 6 commits into
mainfrom
network-access-layer

Conversation

@jackschonherr

Copy link
Copy Markdown
Collaborator

Adds NSI API interaction in the Network project and adds a unit testing project which currently has NSI parsing tests (no network tests right now)

@Brennan1994 Brennan1994 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we have a unit test project, we should turn on testing in the CI/CD. Check out the workflows in this repo. I believe they currently pass a run-tests:false flag. flip that guy to true

Comment thread Consequences.Network/NsiImporter.cs Outdated
/// </summary>
public static class NsiImporter
{
private const string ROOT = "https://nsi.sec.usace.army.mil/nsiapi/";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than hard code this. Lets use forward link. Check the NSI Importer in the ras-ui project for an example. Forward link lets us control this URL from outside the project, so we can fix old version out in the field when the nsi endpoints change. (hopefully they don't, but the lever is nice to have.)

Comment thread Consequences.Network/NsiImporter.cs Outdated
private const string FEATURE_COLLECTION = "&fmt=fc";
private const string FEATURE_STREAM = "&fmt=fs";

private static readonly HttpClient _client = new();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Give this guy a quick peruse. I like the static _client here. Super minor enhancement that follows the microsoft guidelines would be to set up a PooledCollectionLifetime. Other than being nice because it follows the MSdocs, it also means that if we change the fwlink on someone while their process is still running, it'll reset the connection every 15 minutes, instead of waiting till the process exits and boots again. Minor detail, but I'd like to implement. https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also like to hold this guy as a default option, but also allow the NsiImporter to have an httpClient injected, so we could give a mock http for unit testing, then we can write tests to verify good behavior with bed error codes from the server, without ever having to touch the network. dotnet does this download file. https://github.com/dotnet/msbuild/blob/e45cc3de8a44d5b92cdad6e0ca7f8a5852c2afbd/src/Tasks.UnitTests/DownloadFile_Tests.cs#L400 -- notice the mock class. the HTTPMEssageHandler lives inside the Client. we can modify that, and throw it in a http client just like they did, and inject this in for testing. Teh actual download file class https://github.com/dotnet/msbuild/blob/main/src/Tasks/DownloadFile.cs

claude suggestion below.

Suggested change
private static readonly HttpClient _client = new();
public sealed class NsiImporter │
{
// Long-lived shared client. PooledConnectionLifetime bounds DNS staleness so the FWLink │
// can actually repoint a running process; Timeout still guards the header phase (F2). │
private static readonly HttpClient _shared = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(15),
});
│ │
private readonly HttpClient _client;
private readonly Uri _root;
│ │
public NsiImporter(HttpClient? client = null, Uri? root = null)
{
_client = client ?? _shared;
_root = root ?? NsiEndpoints.Default; // the FWLink — see below │
}
│ │
/// <summary>Shared importer against the public NSI service.</summary> │
public static NsiImporter Default { get; } = new();
│ │
public Task<List<Building>> ProcessCollectionAsync(string bbox, CancellationToken ct = default) => ...
}

Comment thread Consequences.Network/NsiImporter.cs Outdated
{
StringBuilder url = new();

url.Append(ROOT);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should still work when ROOT changes to the FWLink check that pattern in ras ui

@Brennan1994 Brennan1994 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still reviewing. just submitting so you've got something till I can finsih

@Brennan1994 Brennan1994 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More comments. Still reviewing.

TryMap(structure, out Building building)
? building
: throw new KeyNotFoundException(
$"NSI structure {structure.FdId} has occupancy type '{structure.Occtype}', " +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there other ways this could potentially come out null that isn't teh occupancy type issue?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryMap will not return null, it returns a bool that will only ever be false when we cannot resolve an occupancy type object from the NSI-provided occtype string. The other building attributes come straight from the NSI

/// <see cref="TryMap"/> if you want to filter deliberately.
/// </exception>
public Building Map(NsiStructure structure) =>
TryMap(structure, out Building building)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryMap is returning a bool. seems odd to not check it for the success state.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are checking the success state here in the ternary expression, if we resolve a building we return it and if not we throw an exception

building = new Building
{
OccupancyType = occupancyType,
Value = (float)structure.ValStruct,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious how these are handled by the NSI. Do these values always have something associated ot them, or can they ever be unknonw? and if htey are unknown, how are they labeled? do they get 0s, -9999, null? etc. Want to make sure we don't silently swallow an unknown and call it zero. Need to ask Nick how the NSI does this. Will didn't have an answer right away.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have seen structures with 0 valstruct, not sure if that means unknown. Surely they cannot have truly zero value

/// <summary>
/// RFC 8142 record separator, prefixed to every feature in an fmt=fs response.
/// </summary>
public const char RecordSeparator = '\u001e';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the feature stream, every returned feature is prefixed with this character, so this is how we know we are looking at a new feature

CancellationToken cancellationToken = default)
{
string root = await ResolveRoot(cancellationToken);
string apiUrl = StructuresEndpoint(root, boundingBox, FEATURE_COLLECTION);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of this yeild return, this method quietly returns a state machine, that gives you a structure each time you iterate over it. That's the idea, but a side effect of that is if any of the args are crap. like bounding box being null, or empty, or mapper being null. we won't actually get that exception till someone iterates over the return. Null/whitespace check at the top of this method to avoid that. So we get the exception right away if we have bad args.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added guards for null or empty. This method is for the feature collection which does not have any yield return, but the stream further along in this file does have that problem. Addressing it there


public static async IAsyncEnumerable<NsiStructure> ParseFeatureStreamAsync(
TextReader reader,
[EnumeratorCancellation] CancellationToken cancellationToken = default)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token attribute here is cool. Had to look it up. nice.

/// Downloads the whole feature collection, projecting each structure with
/// <paramref name="mapper"/>. Swap the mapper to import a different receptor type.
/// </summary>
public async Task<List<TReceptor>> ProcessCollection<TReceptor>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Get stream async throws away the http response object from the server and just gives us the body. That's cool sometimes, but we're losing data on failure. That response object has actionable information in it we should communicate to ourselves and users. We can get around this by breaking the network request into two peices. Read headers, Read Content. Check out the last method here: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http.Json/src/System/Net/Http/Json/HttpClientJsonExtensions.Get.AsyncEnumerable.cs

Dotnet follows this pattern. Read the headers, Read the stream, Deserialize with a yeild return. This is the same game we're playing.

@Brennan1994 Brennan1994 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final comments in.

/// be deserialized straight into <see cref="Building"/> — the mapper supplies what the
/// wire format doesn't.
/// </summary>
public sealed class BuildingMapper : INsiStructureMapper<Building>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also implement tests and a mapper for the lifeloss building? I suspect it's going to reveal some deficiencies in the INsiStructureMapper.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not do this yet because life loss buildings have a number of other required attributes that I do not know how to resolve such as # able-bodied people, access to attic, etc.

using Stream jsonResponse = await _client.GetStreamAsync(apiUrl, cancellationToken);

List<NsiStructure> structures =
await NsiJsonParser.ParseFeatureCollectionAsync(jsonResponse, cancellationToken);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could potentially simplify this a bit: drop the string builder for a regular interpolated string. $"{ROOT}structures?bbox={Uri.EscapeDataString(boudningBox)&fmt={directive}" That Uri.EscapeDataString should protect against rogue whitespace in the bounding box list. like a space after a comma where there shouldn't be.

public sealed record NsiGeometry
{
[JsonPropertyName("type")] public string Type { get; init; } = "";
[JsonPropertyName("coordinates")] public double[] Coordinates { get; init; } = [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Record here implies immutability. This is probably minor, as we're not going to be doing a bunch of NsiGeometry a == NsiGeometry b, but I think it'd be good form to try to make that implied immutability true. I think you can just swap double[] for a ReadOnlyMemory


// PooledConnectionLifetime per the HttpClient guidelines: a client this long-lived would
// otherwise hold connections that never notice DNS moving underneath them.
// https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make the name a little more descriptive? maybe something like GetBuildingsAsync?

});

private readonly HttpClient _client;
private readonly string? _fixedRoot;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use a different word than projecting for this? maybe unpacking? We just use the term projection so much in our domain, I'd like to keep its potential meanings limited.

/// The API root is resolved through <see cref="HecFwLink"/> rather than being compiled in,
/// so NSI can move without a release of this library.
///
/// Most callers want <see cref="Default"/>. Construct one to supply an <see cref="HttpClient"/>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As matter of convention, asynchronous methods should be suffixed with the name async, like HttpClient's are, and include in the name what it returns as specific as you can for the method. Give these guys a review and run through and see if you can incorporate that. And that async suffix doesn't only apply to methods with the keyword async, but ones that return tasks. Official documentation of this is here: https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap . It's more than I think is worth reading unless you're feeling really dedicated. Might be a nice claude summarizable learning material though.

{
private readonly Dictionary<string, OccupancyType> _occupancyTypes;

public BuildingMapper(IEnumerable<OccupancyType> occupancyTypes)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should add a gaurd and exception for duplicate occupancy type names. They'll throw already I think, but you oculd give a more useful message, like which occtype is duplicated

<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a packable true here. We'll want to publish this guy as a nuget

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants