diff --git a/.agents/build-release-ci.md b/.agents/build-release-ci.md index b6192ff..8a87bb9 100644 --- a/.agents/build-release-ci.md +++ b/.agents/build-release-ci.md @@ -8,6 +8,12 @@ Procedure-level detail for humans is in [`RELEASING.md`](../RELEASING.md); this `netstandard2.0` is what covers .NET Framework / older runtimes — there are **no** `net481`/`net462` TFMs anymore (dropped in 2.0, along with `net6.0`). - **DI extension:** mirrors the same three TFMs. +- **Templates package `GoogleMapsApi.Templates`** (`dotnet new` template, ships the `googlemaps-webapi` + template): a `PackageType=Template` package — no build output (`IncludeBuildOutput=false`, + `EnableDefaultCompileItems=false`), so its single `netstandard2.0` TFM is metadata-only. Its + `templates/` tree packs under `content/`. A `BeforeTargets="_GetPackageFiles"` target stamps the + resolved MinVer version into the packed `template.json`, so generated projects reference the + matching `GoogleMapsApi` version. - **Tests:** `net8.0; net10.0`. **Samples:** `net10.0; net8.0`. - `LangVersion=latest`, `Nullable=enable`, `GenerateDocumentationFile=true` (CS1591 suppressed). @@ -18,7 +24,9 @@ Procedure-level detail for humans is in [`RELEASING.md`](../RELEASING.md); this Versions come from **git tags** via **MinVer** (`v`). No version is written in a csproj. `GeneratePackageOnBuild=true`, SourceLink + symbol packages (`.snupkg`) are on. -Both packages are versioned in **lockstep** from the same `v*` tag. +All three packages (`GoogleMapsApi`, `…Extensions.DependencyInjection`, `…Templates`) are versioned in +**lockstep** from the same `v*` tag. Adding a packable project to `GoogleMapsApi.sln` is enough for the +publish workflow to pack and push it — it globs `*.nupkg`, no per-package list. ## Release flow (`release.sh`) diff --git a/CLAUDE.md b/CLAUDE.md index 8a877c7..8ef5689 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,8 @@ one file relevant to your task. A strongly-typed .NET wrapper for the Google Maps Web Services APIs (Geocoding, Directions/Routes, Distance Matrix, Elevation, Time Zone, Places (New), Address Validation, Static Maps). Shipped on -NuGet as `GoogleMapsApi` (+ a sibling `GoogleMapsApi.Extensions.DependencyInjection`). +NuGet as `GoogleMapsApi` (+ siblings `GoogleMapsApi.Extensions.DependencyInjection` and the +`dotnet new` template pack `GoogleMapsApi.Templates`). ## Architecture in 30 seconds @@ -21,7 +22,8 @@ NuGet as `GoogleMapsApi` (+ a sibling `GoogleMapsApi.Extensions.DependencyInject mismatches a compile error. - **Observability is built in:** one OpenTelemetry span per call from `ActivitySource "GoogleMapsApi"`, with the API key/signature redacted from the traced URL. -- **Two packages, lockstep-versioned:** core `GoogleMapsApi` and the optional DI extension. +- **Three packages, lockstep-versioned:** core `GoogleMapsApi`, the optional DI extension, and the + `dotnet new` template pack `GoogleMapsApi.Templates`. Flow: `GoogleMapsClient` → `IEngineFacade` → `HttpClientEngineFacade` (internal) → `MapsAPIGenericEngine` (internal abstract; static-method HTTP+JSON engine). diff --git a/GoogleMapsApi.Templates/.gitignore b/GoogleMapsApi.Templates/.gitignore new file mode 100644 index 0000000..2b187a3 --- /dev/null +++ b/GoogleMapsApi.Templates/.gitignore @@ -0,0 +1,4 @@ +# The files under templates/ are template SOURCE and must be committed verbatim so they ship in +# the NuGet package (CI builds from a clean git checkout). The repo-root .gitignore rule +# `**/appsettings.json` would otherwise exclude the template's own appsettings.json — re-include it. +!templates/**/appsettings.json diff --git a/GoogleMapsApi.Templates/GoogleMapsApi.Templates.csproj b/GoogleMapsApi.Templates/GoogleMapsApi.Templates.csproj new file mode 100644 index 0000000..2f35009 --- /dev/null +++ b/GoogleMapsApi.Templates/GoogleMapsApi.Templates.csproj @@ -0,0 +1,67 @@ + + + netstandard2.0 + v + True + Template + GoogleMapsApi.Templates + Google Maps API project templates + Maxim Novak + BSD-2-Clause + dotnet new templates for getting started with GoogleMapsApi. Includes "googlemaps-webapi" — a minimal ASP.NET Core Web API wired to IGoogleMapsClient. + Copyright © 2010-$([System.DateTime]::Now.Year) + Google;Maps;API;dotnet-new;Template;Scaffold;AspNetCore + git + https://github.com/maximn/google-maps + https://github.com/maximn/google-maps + README.md + true + + false + content + true + $(NoWarn);NU5128;CS1591 + false + false + true + + + + true + + + + + + + + + + + + + + + + + <_TemplateConfig>$(IntermediateOutputPath)template.json + + + + + + + + + + + + diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/dotnetcli.host.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/dotnetcli.host.json new file mode 100644 index 0000000..c9ecbb5 --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/dotnetcli.host.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json.schemastore.org/dotnetcli.host", + "symbolInfo": { + "apikey": { + "longName": "apikey", + "shortName": "" + }, + "Framework": { + "longName": "framework", + "shortName": "f" + } + } +} diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/template.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/template.json new file mode 100644 index 0000000..3703640 --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/template.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Maxim Novak", + "classifications": [ "Web", "WebAPI", "Google Maps" ], + "identity": "GoogleMapsApi.WebApi.CSharp", + "name": "Google Maps Web API", + "description": "A minimal ASP.NET Core Web API wired to GoogleMapsApi with /geocode and /directions endpoints.", + "shortName": "googlemaps-webapi", + "defaultName": "GoogleMapsWebApi", + "sourceName": "GoogleMapsWebApi", + "tags": { + "language": "C#", + "type": "project" + }, + "preferNameDirectory": true, + "sources": [ + { + "rename": { + "gitignore": ".gitignore" + } + } + ], + "symbols": { + "apikey": { + "type": "parameter", + "datatype": "string", + "defaultValue": "", + "replaces": "GOOGLE_API_KEY_PLACEHOLDER", + "description": "Google Maps API key written into appsettings.Development.json (kept out of source control)." + }, + "Framework": { + "type": "parameter", + "datatype": "choice", + "choices": [ + { "choice": "net10.0", "description": ".NET 10.0" }, + { "choice": "net8.0", "description": ".NET 8.0" } + ], + "defaultValue": "net10.0", + "replaces": "FRAMEWORK_TOKEN", + "description": "The target framework for the generated project." + }, + "GoogleMapsApiVersion": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "PACKAGE_VERSION_TOKEN" + }, + "replaces": "GOOGLE_MAPS_API_VERSION" + } + }, + "primaryOutputs": [ + { "path": "GoogleMapsWebApi.csproj" } + ] +} diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/GoogleMapsWebApi.csproj b/GoogleMapsApi.Templates/templates/googlemaps-webapi/GoogleMapsWebApi.csproj new file mode 100644 index 0000000..e5ddf78 --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/GoogleMapsWebApi.csproj @@ -0,0 +1,14 @@ + + + + FRAMEWORK_TOKEN + latest + enable + enable + + + + + + + diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/Program.cs b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Program.cs new file mode 100644 index 0000000..5e3407f --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Program.cs @@ -0,0 +1,94 @@ +using GoogleMapsApi; +using GoogleMapsApi.Entities.Directions.Request; +using GoogleMapsApi.Entities.Geocoding.Request; +using GoogleMapsApi.Entities.Geocoding.Response; + +var builder = WebApplication.CreateBuilder(args); + +string? apiKey = builder.Configuration["GoogleApiKey"] + ?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY"); + +// Register IGoogleMapsClient (IHttpClientFactory-backed) and the ambient API key in one call. +builder.Services.AddGoogleMaps(options => options.ApiKey = apiKey); + +var app = builder.Build(); + +app.MapGet("/geocode", async (string address, IGoogleMapsClient maps) => +{ + if (string.IsNullOrWhiteSpace(apiKey)) + { + return Results.Problem( + "Set your key in appsettings.Development.json (GoogleApiKey) or the GOOGLE_API_KEY env var.", + statusCode: StatusCodes.Status500InternalServerError); + } + + if (string.IsNullOrWhiteSpace(address)) + { + return Results.BadRequest(new { error = "address is required" }); + } + + // The ambient ApiKey from the options above is auto-filled into the request. + var response = await maps.Geocode.QueryAsync(new GeocodingRequest { Address = address }); + + if (response.Status != Status.OK || response.Results is null) + { + return Results.Problem( + $"Geocoding failed: {response.Status}", + statusCode: StatusCodes.Status502BadGateway); + } + + return Results.Ok(response.Results.Select(result => new + { + formattedAddress = result.FormattedAddress, + latitude = result.Geometry.Location.Latitude, + longitude = result.Geometry.Location.Longitude, + placeId = result.PlaceId, + types = result.Types, + })); +}); + +app.MapGet("/directions", async (string origin, string destination, IGoogleMapsClient maps) => +{ + if (string.IsNullOrWhiteSpace(apiKey)) + { + return Results.Problem( + "Set your key in appsettings.Development.json (GoogleApiKey) or the GOOGLE_API_KEY env var.", + statusCode: StatusCodes.Status500InternalServerError); + } + + if (string.IsNullOrWhiteSpace(origin) || string.IsNullOrWhiteSpace(destination)) + { + return Results.BadRequest(new { error = "origin and destination are required" }); + } + + var request = new DirectionsRequest + { + Origin = origin, + Destination = destination, + }; + + var response = await maps.Directions.QueryAsync(request); + + if (response.Status != GoogleMapsApi.Entities.Directions.Response.DirectionsStatusCodes.OK + || response.Routes is null) + { + return Results.Problem( + $"Directions failed: {response.Status} {response.ErrorMessage}", + statusCode: StatusCodes.Status502BadGateway); + } + + var route = response.Routes.First(); + var leg = route.Legs?.FirstOrDefault(); + + return Results.Ok(new + { + summary = route.Summary, + startAddress = leg?.StartAddress, + endAddress = leg?.EndAddress, + distance = leg?.Distance?.Text, + duration = leg?.Duration?.Text, + copyrights = route.Copyrights, + }); +}); + +app.Run(); diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/Properties/launchSettings.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Properties/launchSettings.json new file mode 100644 index 0000000..543069b --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.Development.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.Development.json new file mode 100644 index 0000000..2d2aca8 --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "GoogleApiKey": "GOOGLE_API_KEY_PLACEHOLDER" +} diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/gitignore b/GoogleMapsApi.Templates/templates/googlemaps-webapi/gitignore new file mode 100644 index 0000000..6bc2463 --- /dev/null +++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ + +# Keep your API key out of source control. +appsettings.Development.json diff --git a/GoogleMapsApi.sln b/GoogleMapsApi.sln index 9a7ef2c..7dba16f 100644 --- a/GoogleMapsApi.sln +++ b/GoogleMapsApi.sln @@ -35,6 +35,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoogleMapsApi.Benchmarks", "benchmarks\GoogleMapsApi.Benchmarks\GoogleMapsApi.Benchmarks.csproj", "{6429978B-C1E9-415C-B289-DFBF9451393D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoogleMapsApi.Templates", "GoogleMapsApi.Templates\GoogleMapsApi.Templates.csproj", "{F095D702-59A0-4E5D-9E04-B90F1FF1D708}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -190,6 +192,22 @@ Global {6429978B-C1E9-415C-B289-DFBF9451393D}.Release|x86.Build.0 = Release|Any CPU {6429978B-C1E9-415C-B289-DFBF9451393D}.Release|x64.ActiveCfg = Release|Any CPU {6429978B-C1E9-415C-B289-DFBF9451393D}.Release|x64.Build.0 = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x86.ActiveCfg = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x86.Build.0 = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x64.ActiveCfg = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x64.Build.0 = Debug|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Any CPU.Build.0 = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x86.ActiveCfg = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x86.Build.0 = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x64.ActiveCfg = Release|Any CPU + {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index d25a2c1..151dcb3 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,18 @@ dotnet add package GoogleMapsApi Looking for runnable examples? See [`samples/`](samples/) — console, ASP.NET Core minimal API, and Blazor Server. +## Scaffold a project in 10 seconds + +Spin up a working ASP.NET Core Web API (with `/geocode` and `/directions` endpoints) using the `dotnet new` template: + +``` +dotnet new install GoogleMapsApi.Templates +dotnet new googlemaps-webapi -o MyMapsApi --apikey YOUR_API_KEY +cd MyMapsApi && dotnet run +``` + +The key is written to `appsettings.Development.json` (gitignored), and the generated project references the matching `GoogleMapsApi` version. Pass `-f net8.0` to target .NET 8. + # Quickstart ## API Key Configuration