diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70f3938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +## Shamelessly stolen from https://gist.github.com/takekazuomi/10955889 + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results + +[Dd]ebug/ +[Rr]elease/ +x64/ +[Bb]in/ +[Oo]bj/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.svclog +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml +*.pubxml +*.azurePubxml + +# NuGet Packages Directory +## TODO: If you have NuGet Package Restore enabled, uncomment the next line +packages/ +## TODO: If the tool you use requires repositories.config, also uncomment the next line +!packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +![Ss]tyle[Cc]op.targets +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml + +*.publishsettings + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + +# ========================= +# Windows detritus +# ========================= + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +_NCrunch* \ No newline at end of file diff --git a/README.md b/README.md index df21dc8..a14bb6f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ +# Petar Parushev + +## Notes and thoughts +Took me a while longer that I had hoped it would but I am generally pleased with how this task turned out. My C# is super rusty and I also forgot all of VS's shortcuts. +I developed it mostly using TDD and I have added unit tests for more than the controller classes. I do not have proper endpoint tests - I would add a in-memory API that I call using actual HTTP calls to check routes, parameters, validation and etc. +I am missing validation in controllers. I should add attributres on the query parameters to make sure that they are not null and that the start date should be less than the end date + +## API performance and scale +I converted the API to asynchronos execution in one of the last commits which should make it easier on the threadpool. +The solution is stateless so it can scale indefinetely behind a load balancer. +I am making a call to USGS on every request which can be improved by setting up some sort of cache-ing. Surely there is an earthquake every second but most of them are insignificant and the use case of this system means that we don't need that fresh of data. + + + # Earthquake Challenge ## Was that an Earthquake? diff --git a/topggcsharpchallenge/topggchallengetest/Controllers/EarthquakeControllerTest.cs b/topggcsharpchallenge/topggchallengetest/Controllers/EarthquakeControllerTest.cs new file mode 100644 index 0000000..2e998d7 --- /dev/null +++ b/topggcsharpchallenge/topggchallengetest/Controllers/EarthquakeControllerTest.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; + +using topggcsharpchallenge.Controllers; +using topggcsharpchallenge.Models; +using topggcsharpchallenge.Services; + +namespace topggcsharpchallengetest.Controllers +{ + [TestFixture] + public class EarthquakeControllerTest + { + private readonly Mock earthquakeServiceMock = new Mock(); + + private EarthquakeController sut; + + [SetUp] + public void SetUp() + { + sut = new EarthquakeController(earthquakeServiceMock.Object); + } + + [Test] + public async Task GetShouldBeSuccessfull() + { + int latitude = 10; + int longitude = 20; + DateTime startDate = DateTime.MinValue; + DateTime endDate = DateTime.Now; + IList mockedData = getEarthquakeResponseModelMockData(); + earthquakeServiceMock.Setup((x) => x.Get(latitude, longitude, startDate, endDate)).Returns(Task.FromResult(mockedData)); + + ActionResult> response = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.IsInstanceOf(response.Result); + OkObjectResult result = (OkObjectResult) response.Result; + Assert.That(result.Value, Is.EqualTo(mockedData)); + earthquakeServiceMock.Verify((x) => x.Get(latitude, longitude, startDate, endDate), Times.Once); + } + + + [Test] + public async Task GetShouldReturn404WhenNoQuakesFound() + { + int latitude = 10; + int longitude = 20; + DateTime startDate = DateTime.MinValue; + DateTime endDate = DateTime.Now; + IList mockedData = new List(); + earthquakeServiceMock.Setup((x) => x.Get(latitude, longitude, startDate, endDate)).Returns(Task.FromResult(mockedData)); + + ActionResult> response = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.IsInstanceOf(response.Result); + earthquakeServiceMock.Verify((x) => x.Get(latitude, longitude, startDate, endDate), Times.Once); + } + + private IList getEarthquakeResponseModelMockData() + { + return new List() + { + new EarthquakeResponseModel() + { + Time = DateTime.MinValue, + Latitude = 0.1234, + Longitude = 4.3210, + Depth = 123.456, + Mag = 654.312, + MagType = "md", + Nst = 1, + Gap = 2, + Dmin = 2.34, + Rms = 3.45, + Net = "nc", + Id = "nc73636400", + Updated = DateTime.Now, + Place = "sofia", + Type = "earthquake", + HorizontalError = 0.111, + DepthError = 0.222, + MagError = 0.333, + MagNst = 4, + Status = "automatic", + LocationSource = "nc", + MagSource = "nc" + }, + new EarthquakeResponseModel() + { + Time = DateTime.Now, + Latitude = 0.000001, + Longitude = 0.000002, + Depth = 0.000003, + Mag = 0.000004, + MagType = "dm", + Nst = 200, + Gap = 300, + Dmin = 0.000005, + Rms = 0.000006, + Net = "cn", + Id = "cn73636400", + Updated = DateTime.MaxValue, + Place = "nyc", + Type = "earthquake", + HorizontalError = 0.000007, + DepthError = 0.000008, + MagError = 0.000009, + MagNst = 400, + Status = "reviwed", + LocationSource = "ls", + MagSource = "ms" + }, + }; + } + + } +} diff --git a/topggcsharpchallenge/topggchallengetest/Services/EarthquakeServiceTest.cs b/topggcsharpchallenge/topggchallengetest/Services/EarthquakeServiceTest.cs new file mode 100644 index 0000000..9102f95 --- /dev/null +++ b/topggcsharpchallenge/topggchallengetest/Services/EarthquakeServiceTest.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using topggcsharpchallenge; +using topggcsharpchallenge.Models; +using topggcsharpchallenge.Services; + +namespace topggcsharpchallengetest.Services +{ + [TestFixture] + public class EarthquakeServiceTest + { + private readonly Mock usgsServiceMock = new Mock(); + + private IEarthquakeService sut; + + [SetUp] + public void SetUp() + { + sut = new EarthquakeService(usgsServiceMock.Object); + } + + [Test] + public async Task GetShouldBeSuccessfull() + { + int latitude = 0; + int longitude = 0; + DateTime startDate = DateTime.MinValue; + DateTime endDate = DateTime.MaxValue; + IList mockedEarthquakeData = getUsgsServiceGetEarthquakeDataMocks(); + byte[] csv = createCsv(mockedEarthquakeData); + usgsServiceMock.Setup((x) => x.GetEarthquakeData()).Returns(Task.FromResult(csv)); + IList expectedEarthquakeData = mockedEarthquakeData.OrderByDescending(x => x.Time).ToList(); + + IList actualEarthquakeData = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.That(actualEarthquakeData, Is.EqualTo(expectedEarthquakeData)); + IList dates = actualEarthquakeData.Select(x => x.Time).ToList(); + for (int i = 0; i < dates.Count - 1; i++) + { + Assert.That(dates[i] >= dates[i + 1]); + } + } + + [Test] + public async Task GetShouldReturnEmptyWhenIntervalBeforeAnyQuakes() + { + int latitude = 10; + int longitude = 20; + DateTime startDate = new DateTime(1111, 1, 1); + DateTime endDate = new DateTime(1112, 1, 1); + IList expectedEarthquakeData = getUsgsServiceGetEarthquakeDataMocks(); + byte[] csv = createCsv(expectedEarthquakeData); + usgsServiceMock.Setup((x) => x.GetEarthquakeData()).Returns(Task.FromResult(csv)); + + IEnumerable actualEarthquakeData = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.That(actualEarthquakeData, Is.Empty); + } + + [Test] + public async Task GetShouldReturnEmptyWhenIntervalAfterAnyQuakes() + { + int latitude = 10; + int longitude = 20; + DateTime startDate = new DateTime(2222, 1, 1); + DateTime endDate = new DateTime(2223, 1, 1); + IList expectedEarthquakeData = getUsgsServiceGetEarthquakeDataMocks(); + byte[] csv = createCsv(expectedEarthquakeData); + usgsServiceMock.Setup((x) => x.GetEarthquakeData()).Returns(Task.FromResult(csv)); + + IList actualEarthquakeData = await sut .Get(latitude, longitude, startDate, endDate); + + Assert.That(actualEarthquakeData, Is.Empty); + } + + [Test] + public async Task GetShouldReturnOnlyQuakesWithValidTime() + { + int latitude = 0; + int longitude = 0; + DateTime startDate = new DateTime(1995, 1, 1); + DateTime endDate = new DateTime(2020, 1, 1); + IList expectedEarthquakeData = getUsgsServiceGetEarthquakeDataMocks(); + byte[] csv = createCsv(expectedEarthquakeData); + usgsServiceMock.Setup((x) => x.GetEarthquakeData()).Returns(Task.FromResult(csv)); + + IList actualEarthquakeData = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.That(actualEarthquakeData.Count, Is.EqualTo(1)); + Assert.That(actualEarthquakeData[0], Is.EqualTo(expectedEarthquakeData[1])); + } + + [Test] + public async Task GetShouldReturnNoQuakesWhenThereAreNoneInRange() + { + int latitude = 90; + int longitude = 90; + DateTime startDate = DateTime.MinValue; + DateTime endDate = DateTime.MaxValue; + IList expectedEarthquakeData = getUsgsServiceGetEarthquakeDataMocks(); + byte[] csv = createCsv(expectedEarthquakeData); + usgsServiceMock.Setup((x) => x.GetEarthquakeData()).Returns(Task.FromResult(csv)); + + IList actualEarthquakeData = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.That(actualEarthquakeData, Is.Empty); + } + + [Test] + public async Task GetShouldReturnSomeQuakesWhenTheyAreInRange() + { + int latitude = 10; + int longitude = 10; + DateTime startDate = DateTime.MinValue; + DateTime endDate = DateTime.MaxValue; + IList expectedEarthquakeData = getUsgsServiceGetEarthquakeDataMocks(); + byte[] csv = createCsv(expectedEarthquakeData); + usgsServiceMock.Setup((x) => x.GetEarthquakeData()).Returns(Task.FromResult(csv)); + + IList actualEarthquakeData = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.That(actualEarthquakeData.Count, Is.EqualTo(1)); + Assert.That(actualEarthquakeData[0], Is.EqualTo(expectedEarthquakeData[1])); + } + + [Test] + public async Task GetShouldReturnNoMoreThanTheLimitOfResults() + { + int latitude = 0; + int longitude = 0; + DateTime startDate = DateTime.MinValue; + DateTime endDate = DateTime.MaxValue; + IList expectedEarthquakeData = getUsgsServiceGetEarthquakeDataMocksMany(); + byte[] csv = createCsv(expectedEarthquakeData); + usgsServiceMock.Setup((x) => x.GetEarthquakeData()).Returns(Task.FromResult(csv)); + + IList actualEarthquakeData = await sut.Get(latitude, longitude, startDate, endDate); + + Assert.That(actualEarthquakeData.Count, Is.EqualTo(Constants.EARTHQUAKE_COUNT_LIMIT)); + } + + private IList getUsgsServiceGetEarthquakeDataMocks() + { + return new List() + { + new EarthquakeResponseModel() + { + Time = new DateTime(1993, 9, 29), + Latitude = 0, + Longitude = 0, + Depth = 123.456, + Mag = 0.00000001, + MagType = "md", + Nst = 1, + Gap = 2, + Dmin = 2.34, + Rms = 3.45, + Net = "nc", + Id = "nc73636400", + Updated = new DateTime(1993, 9, 29), + Place = "sofia", + Type = "earthquake", + HorizontalError = 0.111, + DepthError = 0.222, + MagError = 0.333, + MagNst = 4, + Status = "automatic", + LocationSource = "nc", + MagSource = "nc" + }, + new EarthquakeResponseModel() + { + Time = new DateTime(1996, 1, 11), + Latitude = 180, + Longitude = 180, + Depth = 0.000003, + Mag = 20, + MagType = "dm", + Nst = 200, + Gap = 300, + Dmin = 0.000005, + Rms = 0.000006, + Net = "cn", + Id = "cn73636400", + Updated = new DateTime(1996, 1, 11), + Place = "nyc", + Type = "earthquake", + HorizontalError = 0.000007, + DepthError = 0.000008, + MagError = 0.000009, + MagNst = 400, + Status = "reviewed", + LocationSource = "ls", + MagSource = "ms" + }, + }; + } + + private IList getUsgsServiceGetEarthquakeDataMocksMany() + { + return new List() + { + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + new EarthquakeResponseModel(), + }; + } + + private byte[] createCsv(IList quakes) + { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.Append("time,latitude,longitude,depth,mag,magType,nst,gap,dmin,rms,net,id,updated,place,type,horizontalError,depthError,magError,magNst,status,locationSource,magSource"); + foreach (EarthquakeResponseModel quake in quakes) + { + stringBuilder.Append("\n"); + stringBuilder.Append(quake.ToString()); + } + + + return Encoding.ASCII.GetBytes(stringBuilder.ToString()); + } + } +} diff --git a/topggcsharpchallenge/topggchallengetest/topggcsharpchallengetest.csproj b/topggcsharpchallenge/topggchallengetest/topggcsharpchallengetest.csproj new file mode 100644 index 0000000..4f9dfb7 --- /dev/null +++ b/topggcsharpchallenge/topggchallengetest/topggcsharpchallengetest.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + + diff --git a/topggcsharpchallenge/topggcsharpchallenge.sln b/topggcsharpchallenge/topggcsharpchallenge.sln new file mode 100644 index 0000000..0d899ac --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31702.278 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "topggcsharpchallenge", "topggcsharpchallenge\topggcsharpchallenge.csproj", "{EFDE0803-2939-4275-92A9-B2C9355FAE16}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "topggcsharpchallengetest", "topggchallengetest\topggcsharpchallengetest.csproj", "{948C5E86-5E33-410F-8BDA-BC7C666278F0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EFDE0803-2939-4275-92A9-B2C9355FAE16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFDE0803-2939-4275-92A9-B2C9355FAE16}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFDE0803-2939-4275-92A9-B2C9355FAE16}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFDE0803-2939-4275-92A9-B2C9355FAE16}.Release|Any CPU.Build.0 = Release|Any CPU + {948C5E86-5E33-410F-8BDA-BC7C666278F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {948C5E86-5E33-410F-8BDA-BC7C666278F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {948C5E86-5E33-410F-8BDA-BC7C666278F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {948C5E86-5E33-410F-8BDA-BC7C666278F0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {39454A13-FC7F-44EE-A3AE-CEE0FF150ECA} + EndGlobalSection +EndGlobal diff --git a/topggcsharpchallenge/topggcsharpchallenge/AssemblyInfo.cs b/topggcsharpchallenge/topggcsharpchallenge/AssemblyInfo.cs new file mode 100644 index 0000000..215d955 --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/AssemblyInfo.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// In SDK-style projects such as this one, several assembly attributes that were historically +// defined in this file are now automatically added during build and populated with +// values defined in project properties. For details of which attributes are included +// and how to customise this process see: https://aka.ms/assembly-info-properties + + +// Setting ComVisible to false makes the types in this assembly not visible to COM +// components. If you need to access a type in this assembly from COM, set the ComVisible +// attribute to true on that type. + +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM. + +[assembly: Guid("edeaad31-e994-4648-ae4f-346454635958")] + +[assembly: InternalsVisibleTo("topggcsharpchallengetest")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/topggcsharpchallenge/topggcsharpchallenge/Constants.cs b/topggcsharpchallenge/topggcsharpchallenge/Constants.cs new file mode 100644 index 0000000..c318360 --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Constants.cs @@ -0,0 +1,16 @@ +namespace topggcsharpchallenge +{ + public static class Constants + { + public const int EARTH_RADIUS_MILES = 3959; + public const int TRAVEL_DISTANCE_FACTOR = 100; + + // Count could/should be configurable + public const int EARTHQUAKE_COUNT_LIMIT = 10; + + // Url could/should be configurable + public const string USGS_LATEST_REPORT_URL = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.csv"; + + public const string DATE_FORMAT = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'"; + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Controllers/EarthquakeController.cs b/topggcsharpchallenge/topggcsharpchallenge/Controllers/EarthquakeController.cs new file mode 100644 index 0000000..24b9dba --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Controllers/EarthquakeController.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using topggcsharpchallenge.Models; +using topggcsharpchallenge.Services; + +namespace topggcsharpchallenge.Controllers +{ + [ApiController] + [Route("earthquakes")] + public class EarthquakeController : ControllerBase + { + private IEarthquakeService earthquakeService; + + public EarthquakeController(IEarthquakeService earthquakeService) + { + this.earthquakeService = earthquakeService; + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> Get( + [FromQuery(Name = "lat")] double latitude, + [FromQuery(Name = "long")] double longitude, + [FromQuery(Name = "start_date")] DateTime startDate, + [FromQuery(Name = "end_date")] DateTime endDate) + { + IList quakes = await earthquakeService.Get(latitude, longitude, startDate, endDate); + if (quakes.Count == 0) + { + return NotFound(); + } + + return new OkObjectResult(quakes); + } + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Models/EarthquakeResponseModel.cs b/topggcsharpchallenge/topggcsharpchallenge/Models/EarthquakeResponseModel.cs new file mode 100644 index 0000000..c5824ce --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Models/EarthquakeResponseModel.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; + +namespace topggcsharpchallenge.Models +{ + public class EarthquakeResponseModel + { + public DateTime Time { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + public double Depth { get; set; } + public double Mag { get; set; } + public string MagType { get; set; } + public int Nst { get; set; } + public double Gap { get; set; } + public double Dmin { get; set; } + public double Rms { get; set; } + public string Net { get; set; } + public string Id { get; set; } + public DateTime Updated { get; set; } + public string Place { get; set; } + public string Type { get; set; } + public double HorizontalError { get; set; } + public double DepthError { get; set; } + public double MagError { get; set; } + public int MagNst { get; set; } + public string Status { get; set; } + public string LocationSource { get; set; } + public string MagSource { get; set; } + + public override bool Equals(object obj) + { + if (!(obj is EarthquakeResponseModel)) + { + return false; + } + + EarthquakeResponseModel model = (EarthquakeResponseModel)obj; + + return Time == model.Time && + Latitude == model.Latitude && + Longitude == model.Longitude && + Depth == model.Depth && + Mag == model.Mag && + MagType == model.MagType && + Nst == model.Nst && + Gap == model.Gap && + Dmin == model.Dmin && + Rms == model.Rms && + Net == model.Net && + Id == model.Id && + Updated == model.Updated && + Place == model.Place && + Type == model.Type && + HorizontalError == model.HorizontalError && + DepthError == model.DepthError && + MagError == model.MagError && + MagNst == model.MagNst && + Status == model.Status && + LocationSource == model.LocationSource && + MagSource == model.MagSource; + } + + public override int GetHashCode() + { + HashCode hash = new HashCode(); + hash.Add(Time); + hash.Add(Latitude); + hash.Add(Longitude); + hash.Add(Depth); + hash.Add(Mag); + hash.Add(MagType); + hash.Add(Nst); + hash.Add(Gap); + hash.Add(Dmin); + hash.Add(Rms); + hash.Add(Net); + hash.Add(Id); + hash.Add(Updated); + hash.Add(Place); + hash.Add(Type); + hash.Add(HorizontalError); + hash.Add(DepthError); + hash.Add(MagError); + hash.Add(MagNst); + hash.Add(Status); + hash.Add(LocationSource); + hash.Add(MagSource); + return hash.ToHashCode(); + } + + public override string ToString() + { + IList data = new List() + { + Time.ToUniversalTime().ToString(Constants.DATE_FORMAT), + Latitude.ToString(), + Longitude.ToString(), + Depth.ToString(), + Mag.ToString(), + MagType, + Nst.ToString(), + Gap.ToString(), + Dmin.ToString(), + Rms.ToString(), + Net, + Id, + Updated.ToUniversalTime().ToString(Constants.DATE_FORMAT), + Place, + Type, + HorizontalError.ToString(), + DepthError.ToString(), + MagError.ToString(), + MagNst.ToString(), + Status, + LocationSource, + MagSource + }; + + return string.Join(',', data); + } + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Program.cs b/topggcsharpchallenge/topggcsharpchallenge/Program.cs new file mode 100644 index 0000000..a9d3cc8 --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace topggcsharpchallenge +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Properties/launchSettings.json b/topggcsharpchallenge/topggcsharpchallenge/Properties/launchSettings.json new file mode 100644 index 0000000..58fc29e --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52785", + "sslPort": 44379 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "topggcsharpchallenge": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Services/EarthquakeService.cs b/topggcsharpchallenge/topggcsharpchallenge/Services/EarthquakeService.cs new file mode 100644 index 0000000..e968908 --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Services/EarthquakeService.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.VisualBasic.FileIO; + +using topggcsharpchallenge.Models; + +namespace topggcsharpchallenge.Services +{ + public class EarthquakeService : IEarthquakeService + { + private readonly IUsgsService usgsService; + + public EarthquakeService(IUsgsService usgsService) + { + this.usgsService = usgsService; + } + + async Task> IEarthquakeService.Get(double latitude, double longitude, DateTime startDate, DateTime endDate) + { + byte[] earthquakeCsv = await usgsService.GetEarthquakeData(); + + IList earthquakes = ParseEarthquakes(earthquakeCsv); + + return earthquakes + .Where(x => startDate <= x.Time && x.Time <= endDate) + .Where(x => CalculateDistanceInSphere(x.Latitude, x.Longitude, latitude, longitude) <= x.Mag * Constants.TRAVEL_DISTANCE_FACTOR) + .Take(Constants.EARTHQUAKE_COUNT_LIMIT) + .OrderByDescending(x => x.Time) + .ToList(); + } + + private double CalculateDistanceInSphere(double lat1, double long1, double lat2, double long2, int sphereRadius = Constants.EARTH_RADIUS_MILES) + { + int degreesInACircle = 180; + double lat1rad = lat1 * Math.PI / degreesInACircle; + double lat2rad = lat2 * Math.PI / degreesInACircle; + double deltaLat = (lat1 - lat2) * Math.PI / degreesInACircle; + double deltaLong = (long1 - long2) * Math.PI / degreesInACircle; + + double a = Math.Pow(Math.Sin(deltaLat / 2), 2) + Math.Pow(Math.Sin(deltaLong / 2), 2) * Math.Cos(lat1rad) * Math.Cos(lat2rad); + double c = 2 * Math.Asin(Math.Sqrt(a)); + return sphereRadius * c; + } + + private IList ParseEarthquakes(byte[] csv) + { + IList result = new List(); + using (TextFieldParser parser = new TextFieldParser(new MemoryStream(csv))) + { + parser.TextFieldType = FieldType.Delimited; + parser.SetDelimiters(","); + bool headerSkipped = false; + while (!parser.EndOfData) + { + string[] fields = parser.ReadFields(); + if (!headerSkipped) + { + headerSkipped = true; + continue; + } + + result.Add(ParseEarthquake(fields)); + } + } + + return result; + } + + private EarthquakeResponseModel ParseEarthquake(string[] fields) + { + return new EarthquakeResponseModel() + { + Time = parseToDateTimeOrDefault(fields[0]), + Latitude = parseToDoubleOrDefault(fields[1]), + Longitude = parseToDoubleOrDefault(fields[2]), + Depth = parseToDoubleOrDefault(fields[3]), + Mag = parseToDoubleOrDefault(fields[4]), + MagType = fields[5], + Nst = parseToIntegerOrDefault(fields[6]), + Gap = parseToDoubleOrDefault(fields[7]), + Dmin = parseToDoubleOrDefault(fields[8]), + Rms = parseToDoubleOrDefault(fields[9]), + Net = fields[10], + Id = fields[11], + Updated = parseToDateTimeOrDefault(fields[12]), + Place = fields[13], + Type = fields[14], + HorizontalError = parseToDoubleOrDefault(fields[15]), + DepthError = parseToDoubleOrDefault(fields[16]), + MagError = parseToDoubleOrDefault(fields[17]), + MagNst = parseToIntegerOrDefault(fields[18]), + Status = fields[19], + LocationSource = fields[20], + MagSource = fields[21] + }; + } + + private double parseToDoubleOrDefault(string i) + { + return i != string.Empty ? double.Parse(i) : 0; + } + + private int parseToIntegerOrDefault(string i) + { + return i != string.Empty ? int.Parse(i) : 0; + } + + private DateTime parseToDateTimeOrDefault(string date) + { + return date != string.Empty ? DateTime.Parse(date) : new DateTime(); + } + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Services/IEarthquakeService.cs b/topggcsharpchallenge/topggcsharpchallenge/Services/IEarthquakeService.cs new file mode 100644 index 0000000..bf5e233 --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Services/IEarthquakeService.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using topggcsharpchallenge.Models; + +namespace topggcsharpchallenge.Services +{ + public interface IEarthquakeService + { + Task> Get(double latitude, double longitude, DateTime startDate, DateTime endDate); + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Services/IUsgsService.cs b/topggcsharpchallenge/topggcsharpchallenge/Services/IUsgsService.cs new file mode 100644 index 0000000..71b700f --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Services/IUsgsService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace topggcsharpchallenge.Services +{ + public interface IUsgsService + { + Task GetEarthquakeData(); + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Services/UsgsService.cs b/topggcsharpchallenge/topggcsharpchallenge/Services/UsgsService.cs new file mode 100644 index 0000000..f6eadeb --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Services/UsgsService.cs @@ -0,0 +1,18 @@ +using System.Net; +using System.Threading.Tasks; + +namespace topggcsharpchallenge.Services +{ + public class UsgsService : IUsgsService + { + public async Task GetEarthquakeData() + { + string latestReportUrl = Constants.USGS_LATEST_REPORT_URL; + + using (var client = new WebClient()) + { + return await client.DownloadDataTaskAsync(new System.Uri(latestReportUrl)); + } + } + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/Startup.cs b/topggcsharpchallenge/topggcsharpchallenge/Startup.cs new file mode 100644 index 0000000..98f9680 --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/Startup.cs @@ -0,0 +1,76 @@ +using System; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using topggcsharpchallenge.Services; + +namespace topggcsharpchallenge +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + + services.AddScoped(); + services.AddScoped(); + + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Earthquake API", + Version = "v1", + Description = "Provides information on earthquakes.", + Contact = new OpenApiContact + { + Name = "Petar Parushev", + Email = "petergparushev@gmail.com", + Url = new Uri("https://top.gg/"), + }, + }); + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseSwagger(); + + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Earthquake API V1"); + + c.RoutePrefix = string.Empty; + }); + + app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/appsettings.Development.json b/topggcsharpchallenge/topggcsharpchallenge/appsettings.Development.json new file mode 100644 index 0000000..8983e0f --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/appsettings.json b/topggcsharpchallenge/topggcsharpchallenge/appsettings.json new file mode 100644 index 0000000..d9d9a9b --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/topggcsharpchallenge/topggcsharpchallenge/topggcsharpchallenge.csproj b/topggcsharpchallenge/topggcsharpchallenge/topggcsharpchallenge.csproj new file mode 100644 index 0000000..90e0820 --- /dev/null +++ b/topggcsharpchallenge/topggcsharpchallenge/topggcsharpchallenge.csproj @@ -0,0 +1,11 @@ + + + + netcoreapp3.1 + + + + + + +