btcpayserver-plugin-builder/PluginBuilder/Controllers/BtcMapsController.cs
r1ckstardev 3836fe6b38 BTCMaps v2: add btcmap import-RPC submit path alongside directory submission
Adds a second downstream lane to /apis/btcmaps/v1/submit that forwards
the merchant payload to teambtcmap/btcmap-api's submit_place RPC
(merged 2026-05-24 in teambtcmap/btcmap-api#91).

Request schema:

- New fields Lat, Lon, Category, ExternalId on BtcMapsSubmitRequest,
  required iff SubmitToBtcMap=true. Validator enforces lat/lon
  ranges, lowercase-identifier category, and 1-200 char external_id.
  Plugin side (rollforsats/BTCPayServerPlugins) composes external_id
  as hostname:storeId so the namespace stays unique per BTCPay
  instance.
- New SubmitToDirectory + SubmitToBtcMap routing flags. The
  directory flag defaults true to preserve existing callers; btcmap
  defaults false so new callers must opt in.
- New Phone field forwarded as OSM Key:phone in extra_fields.

Service layer:

- BtcMapsService.SubmitToBtcMapAsync POSTs a JSON-RPC 2.0 envelope
  ({jsonrpc, method, params, id}) to BTCMAPS:BtcMapImportEndpoint
  (default https://api.btcmap.org/rpc) with method=submit_place,
  origin=btcpayserver, and the merchant payload mapped to the
  documented param shape. Bearer auth from BTCMAPS:BtcMapImportToken.
- Optional fields (website, description, twitter, github, onion,
  phone, country) ride along in extra_fields using OSM tag keys
  (contact:twitter, addr:country, etc.) plus the implicit
  payment:bitcoin=yes marker.
- New BtcMapTokenMissingException parallels the existing
  DirectoryTokenMissingException so the controller can return 503
  with a distinct error code when ops haven't provisioned the
  scoped token yet.

Controller:

- /apis/btcmaps/v1/submit branches on SubmitToDirectory +
  SubmitToBtcMap. At least one must be true (rejected 400 otherwise).
- Each lane has its own exception ladder symmetric to the existing
  directory path: token-missing 503 (directory-not-configured /
  btcmap-not-configured), caller-cancel rethrow, upstream-timeout
  504, generic-failure 502 - error codes namespaced by lane so ops
  can tell them apart.

HttpClient registration:

- New HttpClientNames.BtcMap named client registered with 15s
  per-call timeout and JSON Accept header, matching the
  BtcMapsDirectory budget for bounded worst-case behavior.

Tests:

- 12 new validation tests in BtcMapsServiceTests covering the
  SubmitToBtcMap=true required-field paths (Lat / Lon / Category /
  ExternalId; range checks; lowercase-identifier policy; overlong
  external_id) plus the default-false directory-only-still-works
  baseline. 37/37 BtcMapsServiceTests passing.
2026-05-25 14:45:46 +00:00

110 lines
5.0 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using PluginBuilder.APIModels;
using PluginBuilder.Services;
namespace PluginBuilder.Controllers;
[ApiController]
[AllowAnonymous]
[Route("~/apis/btcmaps/v1")]
public sealed class BtcMapsController(
BtcMapsService btcMapsService,
ILogger<BtcMapsController> logger)
: ControllerBase
{
[HttpGet("ping")]
public IActionResult Ping() => Ok(new { ok = true, service = "btcmaps", version = "v1" });
[HttpPost("submit")]
[EnableRateLimiting(Policies.BtcMapsSubmitRateLimit)]
public async Task<IActionResult> Submit(
[FromBody] BtcMapsSubmitRequest? request,
CancellationToken cancellationToken)
{
if (request is null)
return BadRequest(new { errors = new[] { new ValidationError("body", "Request body is required.") } });
var errors = btcMapsService.Validate(request);
if (errors.Count > 0)
return BadRequest(new { errors });
// At least one downstream lane must run; "submit nothing" is almost
// certainly a caller bug (forgot to set the flag) rather than a legit
// intent, and silently 200-ing an empty response would hide it.
if (!request.SubmitToDirectory && !request.SubmitToBtcMap)
{
return BadRequest(new { errors = new[] {
new ValidationError("body", "At least one of SubmitToDirectory or SubmitToBtcMap must be true.")
}});
}
var correlationId = Guid.NewGuid().ToString("N");
BtcMapsDirectoryResult? directory = null;
BtcMapsBtcMapResult? btcMap = null;
if (request.SubmitToDirectory)
{
try
{
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
}
catch (BtcMapsService.DirectoryTokenMissingException ex)
{
logger.LogError(ex, "BTCMaps directory submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "directory-not-configured", correlationId });
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "directory-upstream-timeout", correlationId });
}
catch (Exception ex)
{
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status502BadGateway, new { error = "directory-upstream-failed", correlationId });
}
}
if (request.SubmitToBtcMap)
{
try
{
btcMap = await btcMapsService.SubmitToBtcMapAsync(request, cancellationToken);
}
catch (BtcMapsService.BtcMapTokenMissingException ex)
{
logger.LogError(ex, "BTCMaps import submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "btcmap-not-configured", correlationId });
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation(ex, "BTCMaps import submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
logger.LogError(ex, "BTCMaps import submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "btcmap-upstream-timeout", correlationId });
}
catch (Exception ex)
{
logger.LogError(ex, "BTCMaps import submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status502BadGateway, new { error = "btcmap-upstream-failed", correlationId });
}
}
return Ok(new BtcMapsSubmitResponse { Directory = directory, BtcMap = btcMap });
}
}