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.
110 lines
5.0 KiB
C#
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 });
|
|
}
|
|
}
|