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.
This commit is contained in:
parent
d34405970b
commit
3836fe6b38
@ -175,6 +175,116 @@ public class BtcMapsServiceTests
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
|
||||
}
|
||||
|
||||
// BTC Map import-RPC fields are mandatory only when SubmitToBtcMap=true. The
|
||||
// default-false path preserves the directory-only callers untouched.
|
||||
|
||||
private static BtcMapsSubmitRequest MakeValidBtcMap()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.SubmitToBtcMap = true;
|
||||
req.Lat = 51.5074;
|
||||
req.Lon = -0.1278;
|
||||
req.Category = "cafe";
|
||||
req.ExternalId = "store.example.com:abc123";
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsValidBtcMapSubmission()
|
||||
{
|
||||
Assert.Empty(MakeService().Validate(MakeValidBtcMap()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DoesNotRequireBtcMapFieldsByDefault()
|
||||
{
|
||||
// Directory-only callers (the pre-existing shape) must not break: a
|
||||
// request with SubmitToBtcMap unset (default false) and no Lat / Lon /
|
||||
// Category / ExternalId is still valid.
|
||||
Assert.Empty(MakeService().Validate(MakeValid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingLatWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOutOfRangeLat()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = 91.0;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsNaNLat()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = double.NaN;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingLonWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lon = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOutOfRangeLon()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lon = -180.5;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingCategoryWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Category = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsUppercaseCategoryWhenSubmitToBtcMap()
|
||||
{
|
||||
// BTC Map docs: "Use a short, single-word (if possible), lowercase identifier."
|
||||
var req = MakeValidBtcMap();
|
||||
req.Category = "Cafe";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsCategoryWithInvalidCharacters()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Category = "cafe!";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingExternalIdWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.ExternalId = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOverlongExternalId()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.ExternalId = new string('x', 201);
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeUrl_LowercasesSchemeAndHostOnly()
|
||||
{
|
||||
|
||||
@ -13,4 +13,18 @@ public sealed class BtcMapsSubmitRequest
|
||||
public string? Github { get; set; }
|
||||
public string? OnionUrl { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// BTC Map import RPC fields. Required iff SubmitToBtcMap=true.
|
||||
// Plugin captures lat/lon and composes external_id as hostname:storeId
|
||||
// so this endpoint just passes through to the btcmap submit_place RPC.
|
||||
public double? Lat { get; set; }
|
||||
public double? Lon { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
|
||||
// Routing flags. Default-true preserves the existing call-site semantics
|
||||
// for SubmitToDirectory; SubmitToBtcMap defaults false so callers must
|
||||
// opt in to the new path.
|
||||
public bool SubmitToDirectory { get; set; } = true;
|
||||
public bool SubmitToBtcMap { get; set; }
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ namespace PluginBuilder.APIModels;
|
||||
public sealed class BtcMapsSubmitResponse
|
||||
{
|
||||
public BtcMapsDirectoryResult? Directory { get; set; }
|
||||
public BtcMapsBtcMapResult? BtcMap { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsDirectoryResult
|
||||
@ -12,3 +13,10 @@ public sealed class BtcMapsDirectoryResult
|
||||
public string? Branch { get; set; }
|
||||
public string? Skipped { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsBtcMapResult
|
||||
{
|
||||
public long? Id { get; set; }
|
||||
public string? Origin { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
|
||||
@ -30,57 +30,80 @@ public sealed class BtcMapsController(
|
||||
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;
|
||||
BtcMapsDirectoryResult? directory = null;
|
||||
BtcMapsBtcMapResult? btcMap = null;
|
||||
|
||||
try
|
||||
if (request.SubmitToDirectory)
|
||||
{
|
||||
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||
}
|
||||
catch (BtcMapsService.DirectoryTokenMissingException ex)
|
||||
{
|
||||
// Missing token is a server-side deployment / configuration outage,
|
||||
// not a normal "skipped" outcome. Surface 503 so clients (and ops)
|
||||
// can distinguish it from an accepted submission.
|
||||
logger.LogError(ex, "BTCMaps directory submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
|
||||
try
|
||||
{
|
||||
error = "directory-not-configured",
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Caller cancelled (client disconnect, request abort). Rethrow so
|
||||
// the pipeline drops the connection without producing a response
|
||||
// body the client will never read.
|
||||
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
// OCE without caller cancellation = HttpClient.Timeout surfacing as
|
||||
// TaskCanceledException. Treat as an upstream timeout, distinct
|
||||
// from a generic 502 so ops + the plugin client can tell them apart.
|
||||
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||
correlationId, request.Name, request.Url);
|
||||
return StatusCode(StatusCodes.Status504GatewayTimeout, new
|
||||
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||
}
|
||||
catch (BtcMapsService.DirectoryTokenMissingException ex)
|
||||
{
|
||||
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
|
||||
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)
|
||||
{
|
||||
error = "directory-upstream-failed",
|
||||
correlationId
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new BtcMapsSubmitResponse { Directory = directory });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,6 +223,16 @@ public class Program
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
services.AddHttpClient(HttpClientNames.BtcMap, client =>
|
||||
{
|
||||
// BTC Map import RPC is a single JSON-RPC 2.0 dispatch endpoint.
|
||||
// Per-call timeout caps a single round-trip at 15s, matching the
|
||||
// BtcMapsDirectory budget so a hung remote can't pin the request
|
||||
// longer than the per-IP rate-limit window.
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMap/1.0");
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
services.AddHttpClient(HttpClientNames.GitLab, client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
|
||||
|
||||
@ -75,6 +75,15 @@ public sealed class BtcMapsService
|
||||
public DirectoryTokenMissingException() : base("BTCMAPS:DirectoryGithubToken is not configured.") { }
|
||||
}
|
||||
|
||||
public sealed class BtcMapTokenMissingException : Exception
|
||||
{
|
||||
public BtcMapTokenMissingException() : base("BTCMAPS:BtcMapImportToken is not configured.") { }
|
||||
}
|
||||
|
||||
private const string DefaultBtcMapImportEndpoint = "https://api.btcmap.org/rpc";
|
||||
private const string BtcMapImportOrigin = "btcpayserver";
|
||||
private const string BtcMapImportMethod = "submit_place";
|
||||
|
||||
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
@ -130,9 +139,121 @@ public sealed class BtcMapsService
|
||||
}
|
||||
}
|
||||
|
||||
// BTC Map import RPC fields become mandatory only when the caller
|
||||
// opts into that lane via SubmitToBtcMap=true. Directory-only callers
|
||||
// (the existing PR #224 shape) are unaffected.
|
||||
if (request.SubmitToBtcMap)
|
||||
{
|
||||
if (!request.Lat.HasValue || request.Lat.Value is < -90 or > 90 || double.IsNaN(request.Lat.Value))
|
||||
errors.Add(new ValidationError(nameof(request.Lat),
|
||||
"Required when SubmitToBtcMap=true. Must be in [-90, 90]."));
|
||||
|
||||
if (!request.Lon.HasValue || request.Lon.Value is < -180 or > 180 || double.IsNaN(request.Lon.Value))
|
||||
errors.Add(new ValidationError(nameof(request.Lon),
|
||||
"Required when SubmitToBtcMap=true. Must be in [-180, 180]."));
|
||||
|
||||
var category = (request.Category ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(category) || category.Length > 50 ||
|
||||
!category.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_'))
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.Category),
|
||||
"Required when SubmitToBtcMap=true. Short lowercase identifier (a-z, 0-9, -, _; max 50 chars)."));
|
||||
}
|
||||
|
||||
var externalId = (request.ExternalId ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(externalId) || externalId.Length > 200)
|
||||
errors.Add(new ValidationError(nameof(request.ExternalId),
|
||||
"Required when SubmitToBtcMap=true. 1-200 characters."));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
public async Task<BtcMapsBtcMapResult> SubmitToBtcMapAsync(
|
||||
BtcMapsSubmitRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = _configuration["BTCMAPS:BtcMapImportToken"];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new BtcMapTokenMissingException();
|
||||
|
||||
var endpoint = _configuration["BTCMAPS:BtcMapImportEndpoint"] ?? DefaultBtcMapImportEndpoint;
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMap);
|
||||
|
||||
// BTC Map import-RPC takes a JSON-RPC 2.0 envelope at /rpc with method=submit_place.
|
||||
// Required params: origin, external_id, lat, lon, category, name. extra_fields is
|
||||
// an OSM-style tag bag we use to forward the optional surface (website, phone, twitter,
|
||||
// github, onion, description) so a future btcmap-side reviewer has full merchant context
|
||||
// without re-querying us.
|
||||
var extraFields = new Dictionary<string, object?>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Url)) extraFields["website"] = request.Url.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Description)) extraFields["description"] = request.Description.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Phone)) extraFields["phone"] = request.Phone.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Country)) extraFields["addr:country"] = request.Country.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||
{
|
||||
var t = request.Twitter.Trim();
|
||||
extraFields["contact:twitter"] = t.StartsWith("@") ? t : "@" + t;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Github)) extraFields["contact:github"] = request.Github.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.OnionUrl)) extraFields["contact:onion"] = request.OnionUrl.Trim();
|
||||
// Surface payment intent so btcmap can route the place into the right bucket
|
||||
// even before a reviewer touches it. Every submission through this endpoint
|
||||
// is by definition a Bitcoin-accepting merchant.
|
||||
extraFields["payment:bitcoin"] = "yes";
|
||||
|
||||
var rpcParams = new Dictionary<string, object?>
|
||||
{
|
||||
["origin"] = BtcMapImportOrigin,
|
||||
["external_id"] = request.ExternalId!.Trim(),
|
||||
["lat"] = request.Lat!.Value,
|
||||
["lon"] = request.Lon!.Value,
|
||||
["category"] = request.Category!.Trim().ToLowerInvariant(),
|
||||
["name"] = request.Name!.Trim(),
|
||||
["extra_fields"] = extraFields
|
||||
};
|
||||
|
||||
var envelope = new Dictionary<string, object?>
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["method"] = BtcMapImportMethod,
|
||||
["params"] = rpcParams,
|
||||
["id"] = 1
|
||||
};
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
req.Content = new StringContent(JsonSerializer.Serialize(envelope), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(req, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"BtcMap RPC {(int)response.StatusCode} {endpoint}: {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// JSON-RPC 2.0 response shape: either {result: {...}} on success or {error: {...}}.
|
||||
// 2xx status with an error body is a legal JSON-RPC outcome we must surface.
|
||||
if (root.TryGetProperty("error", out var errorElement))
|
||||
{
|
||||
var errorJson = errorElement.GetRawText();
|
||||
throw new HttpRequestException($"BtcMap RPC error response: {errorJson}");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("result", out var result))
|
||||
throw new HttpRequestException($"BtcMap RPC missing result: {body}");
|
||||
|
||||
return new BtcMapsBtcMapResult
|
||||
{
|
||||
Id = result.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number ? id.GetInt64() : null,
|
||||
Origin = result.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
|
||||
ExternalId = result.TryGetProperty("external_id", out var ext) ? ext.GetString() : null
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BtcMapsDirectoryResult> SubmitToDirectoryAsync(
|
||||
BtcMapsSubmitRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user