Compare commits
11 Commits
master
...
btcmaps-v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fa9f947f1 | ||
|
|
dca09b0e04 | ||
|
|
c43f64b960 | ||
|
|
9f4039f6c1 | ||
|
|
098eae8da4 | ||
|
|
a600d3883f | ||
|
|
e6e00146f7 | ||
|
|
70f267a3c9 | ||
|
|
c7b9315160 | ||
|
|
c1ce1a4e12 | ||
|
|
1c7e04a37f |
551
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
551
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
@ -0,0 +1,551 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace PluginBuilder.Tests;
|
||||
|
||||
public class BtcMapsServiceTests
|
||||
{
|
||||
private static BtcMapsService MakeService() =>
|
||||
new BtcMapsService(
|
||||
configuration: new ConfigurationBuilder().Build(),
|
||||
logger: NullLogger<BtcMapsService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiresAtLeastOneAction_NotEnforcedHere()
|
||||
{
|
||||
// The controller enforces (submitToDirectory || tagOnOsm). The service
|
||||
// validator focuses on field-level validity, so an all-false request
|
||||
// with only core fields should still pass Validate cleanly.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Good Shop",
|
||||
Url = "https://goodshop.example",
|
||||
Description = "A very good shop."
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingName()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Url = "https://shop.example",
|
||||
Description = "desc"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsNonHttpsUrl()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "http://plain.example",
|
||||
Description = "desc"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOverlongDescription_OnDirectorySubmit()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = new string('x', 1001),
|
||||
Type = "merchants",
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiresDescription_OnDirectorySubmit()
|
||||
{
|
||||
// Description is the directory PR body content; required only when actually
|
||||
// submitting to the directory.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Type = "merchants",
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsMissingDescription_WhenTagOnOsmOnly()
|
||||
{
|
||||
// tagOnOsm-only requests do not consume Description (the OSM tag set is
|
||||
// name + amenity + currency:XBT + payment:lightning + website). Description
|
||||
// is exclusively a directory-PR field.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true
|
||||
};
|
||||
Assert.DoesNotContain(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsMissingDescription_WhenUnlistOnly()
|
||||
{
|
||||
// unlistFromOsm-only requests strip tags from an existing OSM element; no
|
||||
// Description path on the wire.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
UnlistFromOsm = true,
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("merchants", "books", true)]
|
||||
[InlineData("merchants", "not-a-subtype", false)]
|
||||
[InlineData("apps", "not-a-subtype", true)]
|
||||
public void Validate_ChecksMerchantSubType(string type, string subType, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = type,
|
||||
SubType = subType,
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
var errors = svc.Validate(req);
|
||||
if (expectValid)
|
||||
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
|
||||
else
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsUnknownType_OnDirectorySubmit()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "unicorns",
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SkipsDirectoryFieldsWhenNotSubmitting()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "unicorns",
|
||||
SubmitToDirectory = false
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GLOBAL", true)]
|
||||
[InlineData("US", true)]
|
||||
[InlineData("us", false)]
|
||||
[InlineData("USA", false)]
|
||||
public void Validate_ChecksCountryOnDirectorySubmit(string country, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "merchants",
|
||||
Country = country,
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
var errors = svc.Validate(req);
|
||||
if (expectValid)
|
||||
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
else
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://example.onion", true)]
|
||||
[InlineData("ftp://abc.onion", false)]
|
||||
[InlineData("https://abc.example", false)]
|
||||
[InlineData("http://abc.onion", true)]
|
||||
[InlineData("https://abc.onion", true)]
|
||||
public void Validate_ChecksOnionUrl(string onion, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "merchants",
|
||||
OnionUrl = onion,
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
var errors = svc.Validate(req);
|
||||
if (expectValid)
|
||||
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
|
||||
else
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(123L, "node", true)]
|
||||
[InlineData(123L, "Node", true)]
|
||||
[InlineData(123L, "relation", true)]
|
||||
[InlineData(123L, "line", false)]
|
||||
[InlineData(-1L, "node", false)]
|
||||
public void Validate_ChecksExistingNodeFields(long? nodeId, string? nodeType, bool expectValid)
|
||||
{
|
||||
// Existing-update path: OsmNodeId is set, NodeType must be one of the
|
||||
// known OSM types and the ID positive.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
OsmNodeId = nodeId,
|
||||
OsmNodeType = nodeType,
|
||||
TagOnOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path is nameof(BtcMapsSubmitRequest.OsmNodeId) or nameof(BtcMapsSubmitRequest.OsmNodeType))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40.7128, -74.0060, true)]
|
||||
[InlineData(0.0, 0.0, true)]
|
||||
[InlineData(-90.0, 180.0, true)]
|
||||
[InlineData(90.0, -180.0, true)]
|
||||
[InlineData(91.0, 0.0, false)]
|
||||
[InlineData(-91.0, 0.0, false)]
|
||||
[InlineData(0.0, 181.0, false)]
|
||||
[InlineData(0.0, -181.0, false)]
|
||||
public void Validate_CreatePath_RequiresValidLatLon(double lat, double lon, bool expectValid)
|
||||
{
|
||||
// Create-new path: OsmNodeId is null, lat + lon are required and must be
|
||||
// in valid geographic ranges. NodeType is irrelevant on this path
|
||||
// (server defaults the created element to a node).
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
OsmNodeId = null,
|
||||
Latitude = lat,
|
||||
Longitude = lon,
|
||||
TagOnOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path is nameof(BtcMapsSubmitRequest.Latitude) or nameof(BtcMapsSubmitRequest.Longitude))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CreatePath_RejectsMissingCoordinates()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
OsmNodeId = null,
|
||||
TagOnOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req).ToList();
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Latitude));
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Longitude));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://shop.example/", "https://shop.example/", true)]
|
||||
[InlineData("https://shop.example", "https://shop.example/", true)]
|
||||
[InlineData("https://Shop.Example/", "https://shop.example", true)]
|
||||
[InlineData("https://shop.example/a", "https://shop.example/b", false)]
|
||||
public void NormalizeUrl_IgnoresTrailingSlashAndCase(string a, string b, bool equal)
|
||||
{
|
||||
Assert.Equal(equal, BtcMapsService.NormalizeUrl(a) == BtcMapsService.NormalizeUrl(b));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("9 Bravos", "9-bravos")]
|
||||
[InlineData("Altair Technology", "altair-technology")]
|
||||
[InlineData("!!!", "merchant")]
|
||||
[InlineData(" leading and trailing ", "leading-and-trailing")]
|
||||
public void Slugify_ProducesUrlSafeSlug(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, BtcMapsService.Slugify(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_RequiresOsmNodeIdAndType()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req).ToList();
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeId));
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_AcceptsNodeIdAndType()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
OsmNodeId = 1234,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_RejectsCombinationWithTagOnOsm()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
TagOnOsm = true,
|
||||
OsmNodeId = 1234,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req),
|
||||
e => e.Path == nameof(BtcMapsSubmitRequest.UnlistFromOsm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_RejectsCombinationWithSubmitToDirectory()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
SubmitToDirectory = true,
|
||||
Type = "merchants",
|
||||
OsmNodeId = 1234,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req),
|
||||
e => e.Path == nameof(BtcMapsSubmitRequest.UnlistFromOsm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDirectoryCountry_PrefersTopLevelCountry()
|
||||
{
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Country = "DE",
|
||||
Address = new BtcMapsSubmitAddress { Country = "FR" }
|
||||
};
|
||||
Assert.Equal("DE", BtcMapsService.ResolveDirectoryCountry(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDirectoryCountry_FallsBackToAddressCountry()
|
||||
{
|
||||
// Plugin centralises country in the address block only; the directory
|
||||
// entry should still carry the country code.
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Address = new BtcMapsSubmitAddress { Country = "FR" }
|
||||
};
|
||||
Assert.Equal("FR", BtcMapsService.ResolveDirectoryCountry(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDirectoryCountry_FallsBackThroughWhitespace()
|
||||
{
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Country = " ",
|
||||
Address = new BtcMapsSubmitAddress { Country = " IT " }
|
||||
};
|
||||
Assert.Equal("IT", BtcMapsService.ResolveDirectoryCountry(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDirectoryCountry_NullWhenNeitherProvided()
|
||||
{
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Address = new BtcMapsSubmitAddress { City = "Munich" }
|
||||
};
|
||||
Assert.Null(BtcMapsService.ResolveDirectoryCountry(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsRequestWithoutAddress()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsFullAddressWithIsoCountry()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true,
|
||||
Address = new BtcMapsSubmitAddress
|
||||
{
|
||||
HouseNumber = "12",
|
||||
Street = "Main St",
|
||||
City = "Munich",
|
||||
Postcode = "80331",
|
||||
Country = "DE"
|
||||
}
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsPartialAddress()
|
||||
{
|
||||
// Only some addr:* keys provided. Server writes whichever the plugin
|
||||
// populated; nothing inferred. Empty / missing fields are not errors.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true,
|
||||
Address = new BtcMapsSubmitAddress
|
||||
{
|
||||
City = "Munich",
|
||||
Country = "DE"
|
||||
}
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DE", true)]
|
||||
[InlineData("US", true)]
|
||||
[InlineData("de", false)]
|
||||
[InlineData("DEU", false)]
|
||||
[InlineData("D", false)]
|
||||
[InlineData("GLOBAL", false)] // GLOBAL is valid for the directory's top-level Country, NOT for OSM addr:country.
|
||||
public void Validate_AddressCountry_MustBeIsoAlpha2(string country, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true,
|
||||
Address = new BtcMapsSubmitAddress { Country = country }
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path.EndsWith(nameof(BtcMapsSubmitAddress.Country)))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(-1, false)]
|
||||
[InlineData(1, true)]
|
||||
public void Validate_Unlist_RequiresPositiveNodeId(long id, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
OsmNodeId = id,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeId))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
}
|
||||
}
|
||||
21
PluginBuilder/APIModels/BtcMapsSubmitAddress.cs
Normal file
21
PluginBuilder/APIModels/BtcMapsSubmitAddress.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace PluginBuilder.APIModels;
|
||||
|
||||
// Optional structured address block on BtcMapsSubmitRequest. Populated by the
|
||||
// plugin when the merchant provides postal-address fields; consumed by
|
||||
// BtcMapsService to write OSM `addr:*` tags. Each field is optional - the
|
||||
// service only writes the OSM tags whose corresponding value is populated.
|
||||
//
|
||||
// Field ordering follows the OSM `addr:*` convention. HouseNumber + Street are
|
||||
// kept separate (per OSM) and the plugin is responsible for splitting the raw
|
||||
// merchant-entered street string into the two components before sending.
|
||||
public sealed class BtcMapsSubmitAddress
|
||||
{
|
||||
public string? HouseNumber { get; set; }
|
||||
public string? Street { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Postcode { get; set; }
|
||||
|
||||
// ISO 3166-1 alpha-2. Validated alongside the top-level Country (which is
|
||||
// the directory-submission field) when present; the two are independent.
|
||||
public string? Country { get; set; }
|
||||
}
|
||||
50
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
50
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
@ -0,0 +1,50 @@
|
||||
namespace PluginBuilder.APIModels;
|
||||
|
||||
public sealed class BtcMapsSubmitRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string? Type { get; set; }
|
||||
public string? SubType { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Twitter { get; set; }
|
||||
public string? Github { get; set; }
|
||||
public string? OnionUrl { get; set; }
|
||||
|
||||
public long? OsmNodeId { get; set; }
|
||||
public string? OsmNodeType { get; set; }
|
||||
|
||||
// Required when TagOnOsm=true and OsmNodeId is null (create-new path).
|
||||
// Plugin should pass the merchant's coordinates from the BTCPay store
|
||||
// address or merchant-supplied input.
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
|
||||
// Optional. Maps to the OSM amenity= tag. Common values: shop, cafe,
|
||||
// restaurant, bar, pub, fast_food. Defaults to "shop" when omitted.
|
||||
public string? OsmCategory { get; set; }
|
||||
|
||||
public bool SubmitToDirectory { get; set; }
|
||||
public bool TagOnOsm { get; set; }
|
||||
|
||||
// Defaults to true: a BTCPay store accepts on-chain Bitcoin by definition,
|
||||
// so currency:XBT=yes is always set. Lightning is per-store configuration,
|
||||
// so the plugin must pass the actual store state.
|
||||
public bool AcceptsLightning { get; set; } = true;
|
||||
|
||||
// Opt-in un-listing: remove the bitcoin-related tags from an existing OSM
|
||||
// element. Requires OsmNodeId + OsmNodeType. Mutually exclusive with TagOnOsm
|
||||
// and SubmitToDirectory (v1 scope is OSM-only; directory unlist involves a
|
||||
// separate merchant-row/PR/rebuild flow and is out of scope for this endpoint).
|
||||
// If the target element no longer carries any of the bitcoin-related tags the
|
||||
// service removes, the endpoint returns 409 Conflict.
|
||||
public bool UnlistFromOsm { get; set; }
|
||||
|
||||
// Optional structured address. Consumed by the OSM tag writer (addr:*).
|
||||
// Each field nullable; only populated keys are written to the node. Plugin
|
||||
// is responsible for splitting raw street strings into HouseNumber + Street
|
||||
// at the merchant-form boundary.
|
||||
public BtcMapsSubmitAddress? Address { get; set; }
|
||||
}
|
||||
34
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
34
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
@ -0,0 +1,34 @@
|
||||
namespace PluginBuilder.APIModels;
|
||||
|
||||
public sealed class BtcMapsSubmitResponse
|
||||
{
|
||||
public BtcMapsDirectoryResult? Directory { get; set; }
|
||||
public BtcMapsOsmResult? Osm { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsDirectoryResult
|
||||
{
|
||||
public string? PrUrl { get; set; }
|
||||
public int? PrNumber { get; set; }
|
||||
public string? Branch { get; set; }
|
||||
public string? Skipped { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsOsmResult
|
||||
{
|
||||
public long? ChangesetId { get; set; }
|
||||
public long? NodeId { get; set; }
|
||||
public string? NodeType { get; set; }
|
||||
public int? NewVersion { get; set; }
|
||||
public string? Skipped { get; set; }
|
||||
|
||||
// True when the node was created on this request (OsmNodeId was null in
|
||||
// the request and the service POSTed /api/0.6/node). Plugin should
|
||||
// persist NodeId back to the merchant record so future submissions take
|
||||
// the existing-update path.
|
||||
public bool Created { get; set; }
|
||||
|
||||
// Populated on an un-list request (UnlistFromOsm=true) with the keys the
|
||||
// service actually removed from the element. Null on a tag-on request.
|
||||
public string[]? RemovedTags { get; set; }
|
||||
}
|
||||
110
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
110
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
@ -0,0 +1,110 @@
|
||||
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.") } });
|
||||
|
||||
if (!request.SubmitToDirectory && !request.TagOnOsm && !request.UnlistFromOsm)
|
||||
return BadRequest(new { errors = new[] { new ValidationError("action", "Set submitToDirectory, tagOnOsm, and/or unlistFromOsm to true.") } });
|
||||
|
||||
var errors = btcMapsService.Validate(request);
|
||||
if (errors.Count > 0)
|
||||
return BadRequest(new { errors });
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var response = new BtcMapsSubmitResponse();
|
||||
|
||||
if (request.SubmitToDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
response.Directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
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.UnlistFromOsm)
|
||||
{
|
||||
try
|
||||
{
|
||||
response.Osm = await btcMapsService.UnlistFromOsmAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps OSM un-list failed (correlationId={CorrelationId}) for {Name} node {NodeType}/{NodeId}",
|
||||
correlationId, request.Name, request.OsmNodeType, request.OsmNodeId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new
|
||||
{
|
||||
error = "osm-upstream-failed",
|
||||
correlationId,
|
||||
partial = response
|
||||
});
|
||||
}
|
||||
|
||||
// Conflict surface: when the element already carries none of the
|
||||
// bitcoin-related tags the service removes, the service reports
|
||||
// "already-unlisted" via Skipped. Return 409 so the plugin can
|
||||
// distinguish idempotent no-op from "actually removed just now".
|
||||
if (response.Osm?.Skipped == "already-unlisted")
|
||||
{
|
||||
return Conflict(new
|
||||
{
|
||||
error = "already-unlisted",
|
||||
correlationId,
|
||||
partial = response
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (request.TagOnOsm)
|
||||
{
|
||||
try
|
||||
{
|
||||
response.Osm = await btcMapsService.TagOnOsmAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps OSM tagging failed (correlationId={CorrelationId}) for {Name} node {NodeType}/{NodeId}",
|
||||
correlationId, request.Name, request.OsmNodeType, request.OsmNodeId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new
|
||||
{
|
||||
error = "osm-upstream-failed",
|
||||
correlationId,
|
||||
partial = response
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
@ -4,4 +4,5 @@ public class Policies
|
||||
{
|
||||
public const string OwnPlugin = "OwnPlugin";
|
||||
public const string PublicApiRateLimit = "PublicApiRateLimit";
|
||||
public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit";
|
||||
}
|
||||
|
||||
@ -223,6 +223,7 @@ public class Program
|
||||
services.AddSingleton<EmailService>();
|
||||
services.AddSingleton<FirstBuildEvent>();
|
||||
services.AddSingleton<NostrService>();
|
||||
services.AddSingleton<BtcMapsService>();
|
||||
|
||||
// shared controller logic
|
||||
services.AddSingleton<AdminSettingsCache>();
|
||||
@ -262,6 +263,17 @@ public class Program
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
|
||||
{
|
||||
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromHours(24),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
services.AddOutputCache(options =>
|
||||
|
||||
704
PluginBuilder/Services/BtcMapsService.cs
Normal file
704
PluginBuilder/Services/BtcMapsService.cs
Normal file
@ -0,0 +1,704 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PluginBuilder.APIModels;
|
||||
|
||||
namespace PluginBuilder.Services;
|
||||
|
||||
public sealed class BtcMapsService
|
||||
{
|
||||
private const string DefaultOsmApiBase = "https://api.openstreetmap.org/api/0.6/";
|
||||
private const string DefaultDirectoryRepo = "btcpayserver/directory.btcpayserver.org";
|
||||
private const string DefaultDirectoryMerchantsPath = "src/data/merchants.json";
|
||||
private const string UserAgent = "PluginBuilder-BtcMaps/1.0";
|
||||
|
||||
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"merchants", "apps", "hosted-btcpay", "non-profits"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidMerchantSubTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"3d-printing", "adult", "appliances-furniture", "art", "books",
|
||||
"cryptocurrency-paraphernalia", "domains-hosting-vpns", "education",
|
||||
"electronics", "fashion", "food", "gambling", "gift-cards",
|
||||
"health-household", "holiday-travel", "jewelry", "payment-services",
|
||||
"pets", "services", "software-video-games", "sports", "tools"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidOsmNodeTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"node", "way", "relation"
|
||||
};
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<BtcMapsService> _logger;
|
||||
|
||||
public BtcMapsService(
|
||||
IConfiguration configuration,
|
||||
ILogger<BtcMapsService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
var name = (request.Name ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(name) || name.Length > 200)
|
||||
errors.Add(new ValidationError(nameof(request.Name), "Required, 1-200 characters."));
|
||||
|
||||
var url = (request.Url ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(url))
|
||||
errors.Add(new ValidationError(nameof(request.Url), "Required."));
|
||||
else if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != Uri.UriSchemeHttps)
|
||||
errors.Add(new ValidationError(nameof(request.Url), "Must be a valid https:// URL."));
|
||||
|
||||
if (request.SubmitToDirectory)
|
||||
{
|
||||
// Description is only consumed by the directory PR body; not required for
|
||||
// tagOnOsm-only or unlistFromOsm-only requests.
|
||||
var description = (request.Description ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(description) || description.Length > 1000)
|
||||
errors.Add(new ValidationError(nameof(request.Description), "Required, 1-1000 characters."));
|
||||
|
||||
var type = (request.Type ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(type) || !ValidTypes.Contains(type))
|
||||
errors.Add(new ValidationError(nameof(request.Type),
|
||||
$"Required for directory submission. One of: {string.Join(", ", ValidTypes)}."));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SubType))
|
||||
{
|
||||
var subType = request.SubType.Trim();
|
||||
if (string.Equals(type, "merchants", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ValidMerchantSubTypes.Contains(subType))
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.SubType),
|
||||
"Invalid merchant subtype."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||
{
|
||||
var country = request.Country.Trim();
|
||||
if (!(country == "GLOBAL" || (country.Length == 2 && country.All(char.IsUpper))))
|
||||
errors.Add(new ValidationError(nameof(request.Country),
|
||||
"Must be ISO 3166-1 alpha-2 or GLOBAL."));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
|
||||
{
|
||||
if (!Uri.TryCreate(request.OnionUrl.Trim(), UriKind.Absolute, out var onionUri) ||
|
||||
(onionUri.Scheme != Uri.UriSchemeHttp && onionUri.Scheme != Uri.UriSchemeHttps) ||
|
||||
!onionUri.Host.EndsWith(".onion", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.OnionUrl),
|
||||
"Must be an http(s) .onion URL."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.TagOnOsm)
|
||||
{
|
||||
if (request.OsmNodeId is null)
|
||||
{
|
||||
// Create-new path: lat + lon required; OsmNodeType defaults to "node"
|
||||
// when the service creates the OSM element.
|
||||
if (request.Latitude is null || request.Latitude < -90.0 || request.Latitude > 90.0)
|
||||
errors.Add(new ValidationError(nameof(request.Latitude),
|
||||
"Required when OsmNodeId is null. Must be in range [-90, 90]."));
|
||||
if (request.Longitude is null || request.Longitude < -180.0 || request.Longitude > 180.0)
|
||||
errors.Add(new ValidationError(nameof(request.Longitude),
|
||||
"Required when OsmNodeId is null. Must be in range [-180, 180]."));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (request.OsmNodeId <= 0)
|
||||
errors.Add(new ValidationError(nameof(request.OsmNodeId),
|
||||
"Must be positive."));
|
||||
|
||||
var nodeType = (request.OsmNodeType ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(nodeType) || !ValidOsmNodeTypes.Contains(nodeType))
|
||||
errors.Add(new ValidationError(nameof(request.OsmNodeType),
|
||||
$"Required when OsmNodeId is set. One of: {string.Join(", ", ValidOsmNodeTypes)}."));
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Address is not null && !string.IsNullOrWhiteSpace(request.Address.Country))
|
||||
{
|
||||
var addrCountry = request.Address.Country.Trim();
|
||||
if (!(addrCountry.Length == 2 && addrCountry.All(char.IsUpper)))
|
||||
errors.Add(new ValidationError($"{nameof(request.Address)}.{nameof(request.Address.Country)}",
|
||||
"Must be ISO 3166-1 alpha-2."));
|
||||
}
|
||||
|
||||
if (request.UnlistFromOsm)
|
||||
{
|
||||
// Un-listing always targets an existing element - there is no "remove-from-new-node"
|
||||
// path. Mutually exclusive with TagOnOsm (opposite intent) and with SubmitToDirectory
|
||||
// (v1 scope is OSM-only; directory unlist is a separate flow).
|
||||
if (request.TagOnOsm)
|
||||
errors.Add(new ValidationError(nameof(request.UnlistFromOsm),
|
||||
"Cannot be combined with tagOnOsm (opposite intent)."));
|
||||
if (request.SubmitToDirectory)
|
||||
errors.Add(new ValidationError(nameof(request.UnlistFromOsm),
|
||||
"Cannot be combined with submitToDirectory (directory unlist is out of v1 scope)."));
|
||||
|
||||
if (request.OsmNodeId is null || request.OsmNodeId <= 0)
|
||||
errors.Add(new ValidationError(nameof(request.OsmNodeId),
|
||||
"Required when unlistFromOsm is true. Must be positive."));
|
||||
|
||||
var nodeType = (request.OsmNodeType ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(nodeType) || !ValidOsmNodeTypes.Contains(nodeType))
|
||||
errors.Add(new ValidationError(nameof(request.OsmNodeType),
|
||||
$"Required when unlistFromOsm is true. One of: {string.Join(", ", ValidOsmNodeTypes)}."));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
public async Task<BtcMapsDirectoryResult> SubmitToDirectoryAsync(
|
||||
BtcMapsSubmitRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = _configuration["BTCMAPS:DirectoryGithubToken"];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return new BtcMapsDirectoryResult { Skipped = "directory-github-token-not-configured" };
|
||||
|
||||
var repo = _configuration["BTCMAPS:DirectoryRepo"] ?? DefaultDirectoryRepo;
|
||||
var merchantsPath = _configuration["BTCMAPS:DirectoryMerchantsPath"] ?? DefaultDirectoryMerchantsPath;
|
||||
|
||||
using var client = new HttpClient();
|
||||
client.BaseAddress = new Uri("https://api.github.com/");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
|
||||
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var repoInfo = await GetJsonAsync(client, $"repos/{repo}", cancellationToken);
|
||||
var defaultBranch = repoInfo.GetProperty("default_branch").GetString()
|
||||
?? throw new InvalidOperationException("default_branch missing");
|
||||
|
||||
var fileInfo = await GetJsonAsync(
|
||||
client,
|
||||
$"repos/{repo}/contents/{merchantsPath}?ref={Uri.EscapeDataString(defaultBranch)}",
|
||||
cancellationToken);
|
||||
var contentB64 = fileInfo.GetProperty("content").GetString() ?? string.Empty;
|
||||
var fileSha = fileInfo.GetProperty("sha").GetString() ?? string.Empty;
|
||||
var currentJson = Encoding.UTF8.GetString(Convert.FromBase64String(contentB64.Replace("\n", string.Empty)));
|
||||
|
||||
var merchants = JsonSerializer.Deserialize<List<JsonElement>>(currentJson)
|
||||
?? throw new InvalidOperationException("merchants.json must be a JSON array");
|
||||
|
||||
var normalizedUrl = NormalizeUrl(request.Url!);
|
||||
foreach (var existing in merchants)
|
||||
{
|
||||
if (existing.TryGetProperty("url", out var existingUrl) &&
|
||||
existingUrl.ValueKind == JsonValueKind.String &&
|
||||
NormalizeUrl(existingUrl.GetString() ?? string.Empty) == normalizedUrl)
|
||||
{
|
||||
var existingName = existing.TryGetProperty("name", out var n) ? n.GetString() : "(unknown)";
|
||||
return new BtcMapsDirectoryResult { Skipped = $"duplicate-url:{existingName}" };
|
||||
}
|
||||
}
|
||||
|
||||
var marker = BuildUrlMarker(normalizedUrl);
|
||||
var openPrSearch = await GetJsonAsync(
|
||||
client,
|
||||
$"search/issues?q={Uri.EscapeDataString($"repo:{repo} is:pr is:open in:body \"{marker}\"")}",
|
||||
cancellationToken);
|
||||
if (openPrSearch.TryGetProperty("total_count", out var totalCount) && totalCount.GetInt32() > 0)
|
||||
{
|
||||
var firstItem = openPrSearch.GetProperty("items")[0];
|
||||
return new BtcMapsDirectoryResult
|
||||
{
|
||||
Skipped = "duplicate-open-pr",
|
||||
PrUrl = firstItem.TryGetProperty("html_url", out var h) ? h.GetString() : null,
|
||||
PrNumber = firstItem.TryGetProperty("number", out var n) ? n.GetInt32() : null
|
||||
};
|
||||
}
|
||||
|
||||
var newEntry = BuildMerchantEntry(request);
|
||||
var updated = merchants
|
||||
.Select(e => (JsonElement?)e)
|
||||
.Append(newEntry)
|
||||
.OrderBy(e => e!.Value.TryGetProperty("name", out var n) ? n.GetString() : string.Empty,
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.Select(e => e!.Value)
|
||||
.ToList();
|
||||
|
||||
// Use UnsafeRelaxedJsonEscaping so non-ASCII codepoints and HTML-only "unsafe"
|
||||
// chars (`&`, `'`, `<`, `>`) are written raw in the file, matching the upstream
|
||||
// merchants.json convention. The default JavaScriptEncoder is HTML-safe and
|
||||
// would re-encode every entry containing `'` or non-ASCII as `\uXXXX`, which
|
||||
// shows up as a noisy full-file diff on every append.
|
||||
var updatedJson = JsonSerializer.Serialize(updated, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
}) + "\n";
|
||||
|
||||
var branchRef = await GetJsonAsync(
|
||||
client,
|
||||
$"repos/{repo}/git/ref/heads/{Uri.EscapeDataString(defaultBranch)}",
|
||||
cancellationToken);
|
||||
var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString()
|
||||
?? throw new InvalidOperationException("base sha missing");
|
||||
|
||||
var branchSuffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var branchName = $"btcmaps/{Slugify(request.Name!)}-{branchSuffix}";
|
||||
await PostJsonAsync(client, $"repos/{repo}/git/refs",
|
||||
new { @ref = $"refs/heads/{branchName}", sha = baseSha }, cancellationToken);
|
||||
|
||||
await PutJsonAsync(client, $"repos/{repo}/contents/{merchantsPath}",
|
||||
new
|
||||
{
|
||||
message = $"Add {request.Name}",
|
||||
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(updatedJson)),
|
||||
sha = fileSha,
|
||||
branch = branchName
|
||||
}, cancellationToken);
|
||||
|
||||
var prBody = BuildPrBody(request, marker);
|
||||
var prResponse = await PostJsonAsync(client, $"repos/{repo}/pulls",
|
||||
new
|
||||
{
|
||||
title = $"Add {request.Name}",
|
||||
head = branchName,
|
||||
@base = defaultBranch,
|
||||
body = prBody
|
||||
}, cancellationToken);
|
||||
|
||||
return new BtcMapsDirectoryResult
|
||||
{
|
||||
PrUrl = prResponse.GetProperty("html_url").GetString(),
|
||||
PrNumber = prResponse.GetProperty("number").GetInt32(),
|
||||
Branch = branchName
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BtcMapsOsmResult> TagOnOsmAsync(
|
||||
BtcMapsSubmitRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = _configuration["BTCMAPS:OsmAccessToken"];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return new BtcMapsOsmResult { Skipped = "osm-access-token-not-configured" };
|
||||
|
||||
var apiBase = _configuration["BTCMAPS:OsmApiBase"] ?? DefaultOsmApiBase;
|
||||
var isCreate = request.OsmNodeId is null;
|
||||
var nodeType = isCreate ? "node" : request.OsmNodeType!.ToLowerInvariant();
|
||||
|
||||
using var client = new HttpClient { BaseAddress = new Uri(apiBase) };
|
||||
client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var changesetComment = isCreate
|
||||
? $"Add {request.Name} as a bitcoin-accepting place via BTCPay Server #btcmap"
|
||||
: $"Tag {request.Name} as accepting bitcoin via BTCPay Server #btcmap";
|
||||
var changesetXml = new XDocument(
|
||||
new XElement("osm",
|
||||
new XElement("changeset",
|
||||
new XElement("tag", new XAttribute("k", "created_by"), new XAttribute("v", UserAgent)),
|
||||
new XElement("tag", new XAttribute("k", "comment"), new XAttribute("v", changesetComment)),
|
||||
new XElement("tag", new XAttribute("k", "source"), new XAttribute("v", "BTCPay Server plugin-builder")))));
|
||||
|
||||
var csResponse = await client.PutAsync("changeset/create",
|
||||
new StringContent(changesetXml.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
|
||||
csResponse.EnsureSuccessStatusCode();
|
||||
var changesetId = long.Parse(await csResponse.Content.ReadAsStringAsync(cancellationToken));
|
||||
|
||||
try
|
||||
{
|
||||
long nodeId;
|
||||
int newVersion;
|
||||
|
||||
if (isCreate)
|
||||
{
|
||||
// Build a brand-new <node> with the merchant's tags. OSM accepts the
|
||||
// POST /api/0.6/node body as <osm><node ...><tag .../></node></osm>
|
||||
// and returns the freshly-allocated node ID as plain text.
|
||||
var amenity = string.IsNullOrWhiteSpace(request.OsmCategory)
|
||||
? "shop"
|
||||
: request.OsmCategory.Trim();
|
||||
|
||||
var newNode = new XElement("node",
|
||||
new XAttribute("changeset", changesetId),
|
||||
new XAttribute("lat", request.Latitude!.Value.ToString("R", CultureInfo.InvariantCulture)),
|
||||
new XAttribute("lon", request.Longitude!.Value.ToString("R", CultureInfo.InvariantCulture)));
|
||||
newNode.Add(new XElement("tag", new XAttribute("k", "name"), new XAttribute("v", request.Name!.Trim())));
|
||||
newNode.Add(new XElement("tag", new XAttribute("k", "amenity"), new XAttribute("v", amenity)));
|
||||
newNode.Add(new XElement("tag", new XAttribute("k", "currency:XBT"), new XAttribute("v", "yes")));
|
||||
// BTC Map verification stamp - bumped on every tag operation per
|
||||
// https://gitea.btcmap.org/teambtcmap/btcmap-general/wiki/Verifying-Existing-Merchants
|
||||
// Date-only UTC; the act of submitting through the plugin is itself the verification.
|
||||
newNode.Add(new XElement("tag", new XAttribute("k", "check_date:currency:XBT"), new XAttribute("v", TodayUtcDate())));
|
||||
if (!string.IsNullOrWhiteSpace(request.Url))
|
||||
newNode.Add(new XElement("tag", new XAttribute("k", "website"), new XAttribute("v", request.Url.Trim())));
|
||||
if (request.AcceptsLightning)
|
||||
newNode.Add(new XElement("tag", new XAttribute("k", "payment:lightning"), new XAttribute("v", "yes")));
|
||||
AddAddressTagsToNewNode(newNode, request.Address);
|
||||
|
||||
var createDoc = new XDocument(new XElement("osm", newNode));
|
||||
var createResponse = await client.PutAsync("node/create",
|
||||
new StringContent(createDoc.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
nodeId = long.Parse(await createResponse.Content.ReadAsStringAsync(cancellationToken));
|
||||
newVersion = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
nodeId = request.OsmNodeId!.Value;
|
||||
var elementPath = $"{nodeType}/{nodeId}";
|
||||
var elementXmlText = await client.GetStringAsync(elementPath, cancellationToken);
|
||||
var elementDoc = XDocument.Parse(elementXmlText);
|
||||
var elementEl = elementDoc.Root?.Element(nodeType)
|
||||
?? throw new InvalidOperationException($"OSM element <{nodeType}> not found in response");
|
||||
|
||||
elementEl.SetAttributeValue("changeset", changesetId);
|
||||
|
||||
// Bitcoin acceptance: per OSM, payment:bitcoin=yes is deprecated in favor
|
||||
// of currency:XBT=yes (XBT is ISO 4217). Lightning is gated on the
|
||||
// request's AcceptsLightning flag (per-store config).
|
||||
SetOsmTag(elementEl, "currency:XBT", "yes");
|
||||
// BTC Map verification stamp - same date-only UTC stamp as the create
|
||||
// path, bumped here on re-verify or on any tag-update flow.
|
||||
SetOsmTag(elementEl, "check_date:currency:XBT", TodayUtcDate());
|
||||
if (!string.IsNullOrWhiteSpace(request.Url))
|
||||
SetOsmTag(elementEl, "website", request.Url);
|
||||
if (request.AcceptsLightning)
|
||||
SetOsmTag(elementEl, "payment:lightning", "yes");
|
||||
ApplyAddressTags(elementEl, request.Address);
|
||||
|
||||
var putResponse = await client.PutAsync(elementPath,
|
||||
new StringContent(elementDoc.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
|
||||
putResponse.EnsureSuccessStatusCode();
|
||||
newVersion = int.Parse(await putResponse.Content.ReadAsStringAsync(cancellationToken));
|
||||
}
|
||||
|
||||
return new BtcMapsOsmResult
|
||||
{
|
||||
ChangesetId = changesetId,
|
||||
NodeId = nodeId,
|
||||
NodeType = nodeType,
|
||||
NewVersion = newVersion,
|
||||
Created = isCreate
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await client.PutAsync($"changeset/{changesetId}/close",
|
||||
new StringContent(string.Empty), closeCts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to close OSM changeset {ChangesetId}", changesetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bitcoin-acceptance tags this service removes when un-listing. Keeps `website`,
|
||||
// `name`, `amenity`, and address tags intact since those are not bitcoin-specific
|
||||
// (a venue may remain on OSM after it stops accepting bitcoin). payment:bitcoin
|
||||
// is included for historical nodes tagged before the deprecation-vs-currency:XBT
|
||||
// switch.
|
||||
private static readonly string[] BitcoinAcceptanceTagKeys =
|
||||
{
|
||||
"currency:XBT",
|
||||
"payment:bitcoin",
|
||||
"payment:lightning",
|
||||
"payment:onchain"
|
||||
};
|
||||
|
||||
public async Task<BtcMapsOsmResult> UnlistFromOsmAsync(
|
||||
BtcMapsSubmitRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = _configuration["BTCMAPS:OsmAccessToken"];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return new BtcMapsOsmResult { Skipped = "osm-access-token-not-configured" };
|
||||
|
||||
var apiBase = _configuration["BTCMAPS:OsmApiBase"] ?? DefaultOsmApiBase;
|
||||
var nodeType = request.OsmNodeType!.ToLowerInvariant();
|
||||
var nodeId = request.OsmNodeId!.Value;
|
||||
var elementPath = $"{nodeType}/{nodeId}";
|
||||
|
||||
using var client = new HttpClient { BaseAddress = new Uri(apiBase) };
|
||||
client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
// Fetch first so we can skip the changeset entirely when the element already
|
||||
// has none of the bitcoin-related tags we remove. Idempotency + 409 surface.
|
||||
var elementXmlText = await client.GetStringAsync(elementPath, cancellationToken);
|
||||
var elementDoc = XDocument.Parse(elementXmlText);
|
||||
var elementEl = elementDoc.Root?.Element(nodeType)
|
||||
?? throw new InvalidOperationException($"OSM element <{nodeType}> not found in response");
|
||||
|
||||
var removableKeys = BitcoinAcceptanceTagKeys
|
||||
.Where(k => elementEl.Elements("tag").Any(t => (string?)t.Attribute("k") == k))
|
||||
.ToArray();
|
||||
|
||||
if (removableKeys.Length == 0)
|
||||
{
|
||||
// Nothing to remove - the element already carries no bitcoin-acceptance
|
||||
// tags we own. Report it so the controller surfaces 409 to the plugin
|
||||
// (distinguishes idempotent no-op from "removed just now").
|
||||
return new BtcMapsOsmResult
|
||||
{
|
||||
NodeId = nodeId,
|
||||
NodeType = nodeType,
|
||||
Skipped = "already-unlisted"
|
||||
};
|
||||
}
|
||||
|
||||
var changesetXml = new XDocument(
|
||||
new XElement("osm",
|
||||
new XElement("changeset",
|
||||
new XElement("tag", new XAttribute("k", "created_by"), new XAttribute("v", UserAgent)),
|
||||
new XElement("tag", new XAttribute("k", "comment"), new XAttribute("v", $"Un-list {request.Name} from bitcoin-accepting places via BTCPay Server #btcmap")),
|
||||
new XElement("tag", new XAttribute("k", "source"), new XAttribute("v", "BTCPay Server plugin-builder")))));
|
||||
|
||||
var csResponse = await client.PutAsync("changeset/create",
|
||||
new StringContent(changesetXml.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
|
||||
csResponse.EnsureSuccessStatusCode();
|
||||
var changesetId = long.Parse(await csResponse.Content.ReadAsStringAsync(cancellationToken));
|
||||
|
||||
try
|
||||
{
|
||||
elementEl.SetAttributeValue("changeset", changesetId);
|
||||
foreach (var key in removableKeys)
|
||||
RemoveOsmTag(elementEl, key);
|
||||
|
||||
var putResponse = await client.PutAsync(elementPath,
|
||||
new StringContent(elementDoc.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
|
||||
putResponse.EnsureSuccessStatusCode();
|
||||
var newVersion = int.Parse(await putResponse.Content.ReadAsStringAsync(cancellationToken));
|
||||
|
||||
return new BtcMapsOsmResult
|
||||
{
|
||||
ChangesetId = changesetId,
|
||||
NodeId = nodeId,
|
||||
NodeType = nodeType,
|
||||
NewVersion = newVersion,
|
||||
RemovedTags = removableKeys
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await client.PutAsync($"changeset/{changesetId}/close",
|
||||
new StringContent(string.Empty), closeCts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to close OSM changeset {ChangesetId}", changesetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveOsmTag(XElement element, string key)
|
||||
{
|
||||
var existing = element.Elements("tag").FirstOrDefault(t => (string?)t.Attribute("k") == key);
|
||||
existing?.Remove();
|
||||
}
|
||||
|
||||
private static void SetOsmTag(XElement element, string key, string value)
|
||||
{
|
||||
var existing = element.Elements("tag").FirstOrDefault(t => (string?)t.Attribute("k") == key);
|
||||
if (existing is not null)
|
||||
existing.SetAttributeValue("v", value);
|
||||
else
|
||||
element.Add(new XElement("tag", new XAttribute("k", key), new XAttribute("v", value)));
|
||||
}
|
||||
|
||||
private static string TodayUtcDate() =>
|
||||
DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
|
||||
// OSM addr:* writers. Plugin-side splits the raw merchant address into
|
||||
// structured components; the server writes only the keys whose values are
|
||||
// populated, never inferring or synthesising. This is the create-path
|
||||
// helper (appends new <tag> children to a fresh <node>).
|
||||
private static void AddAddressTagsToNewNode(XElement newNode, BtcMapsSubmitAddress? address)
|
||||
{
|
||||
if (address is null) return;
|
||||
foreach (var (key, raw) in EnumerateAddressTags(address))
|
||||
{
|
||||
var value = raw.Trim();
|
||||
if (value.Length == 0) continue;
|
||||
newNode.Add(new XElement("tag", new XAttribute("k", key), new XAttribute("v", value)));
|
||||
}
|
||||
}
|
||||
|
||||
// Update-path helper: applies addr:* via SetOsmTag so existing values get
|
||||
// overwritten in-place rather than producing duplicate <tag> children.
|
||||
private static void ApplyAddressTags(XElement element, BtcMapsSubmitAddress? address)
|
||||
{
|
||||
if (address is null) return;
|
||||
foreach (var (key, raw) in EnumerateAddressTags(address))
|
||||
{
|
||||
var value = raw.Trim();
|
||||
if (value.Length == 0) continue;
|
||||
SetOsmTag(element, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Key, string Value)> EnumerateAddressTags(BtcMapsSubmitAddress address)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(address.HouseNumber)) yield return ("addr:housenumber", address.HouseNumber);
|
||||
if (!string.IsNullOrWhiteSpace(address.Street)) yield return ("addr:street", address.Street);
|
||||
if (!string.IsNullOrWhiteSpace(address.City)) yield return ("addr:city", address.City);
|
||||
if (!string.IsNullOrWhiteSpace(address.Postcode)) yield return ("addr:postcode", address.Postcode);
|
||||
if (!string.IsNullOrWhiteSpace(address.Country)) yield return ("addr:country", address.Country);
|
||||
}
|
||||
|
||||
private static JsonElement BuildMerchantEntry(BtcMapsSubmitRequest request)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("name", request.Name!.Trim());
|
||||
w.WriteString("url", request.Url!.Trim());
|
||||
w.WriteString("description", request.Description!.Trim());
|
||||
w.WriteString("type", request.Type!.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(request.SubType))
|
||||
w.WriteString("subType", request.SubType.Trim());
|
||||
var directoryCountry = ResolveDirectoryCountry(request);
|
||||
if (!string.IsNullOrWhiteSpace(directoryCountry))
|
||||
w.WriteString("country", directoryCountry);
|
||||
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||
{
|
||||
var t = request.Twitter.Trim();
|
||||
w.WriteString("twitter", t.StartsWith("@") ? t : "@" + t);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Github))
|
||||
w.WriteString("github", request.Github.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
|
||||
w.WriteString("onionUrl", request.OnionUrl.Trim());
|
||||
w.WriteEndObject();
|
||||
}
|
||||
ms.Position = 0;
|
||||
using var doc = JsonDocument.Parse(ms);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
// Plugins centralise the merchant's country in one form field. The directory
|
||||
// submission consumes top-level `country`; OSM addr:country reads
|
||||
// `Address.Country`. If only one is sent, fall back to the other so the
|
||||
// merchant.json entry carries the country regardless of which field the
|
||||
// plugin populated. Top-level wins because it allows the directory-only
|
||||
// GLOBAL pseudonym (e.g. online-only services) which has no OSM addr:*
|
||||
// equivalent.
|
||||
public static string? ResolveDirectoryCountry(BtcMapsSubmitRequest request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||
return request.Country.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Address?.Country))
|
||||
return request.Address!.Country!.Trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildPrBody(BtcMapsSubmitRequest request, string urlMarker)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Name:** {request.Name}");
|
||||
sb.AppendLine($"- **URL:** {request.Url}");
|
||||
sb.AppendLine($"- **Type:** {request.Type}{(string.IsNullOrWhiteSpace(request.SubType) ? string.Empty : " / " + request.SubType)}");
|
||||
var prBodyCountry = ResolveDirectoryCountry(request);
|
||||
if (!string.IsNullOrWhiteSpace(prBodyCountry)) sb.AppendLine($"- **Country:** {prBodyCountry}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||
{
|
||||
// Render as an explicit https://x.com/<handle> link so GitHub markdown does
|
||||
// not auto-resolve a bare `@handle` to github.com/<handle>.
|
||||
var raw = request.Twitter.Trim();
|
||||
var handle = raw.StartsWith("@") ? raw[1..] : raw;
|
||||
sb.AppendLine($"- **Twitter:** [@{handle}](https://x.com/{handle})");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Github)) sb.AppendLine($"- **GitHub:** {request.Github}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**Description:**");
|
||||
sb.AppendLine(request.Description);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("_Please review before merge - this PR was opened programmatically by a BTCMap-plugin merchant submission, not by a maintainer._");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"<!-- {urlMarker} -->");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildUrlMarker(string normalizedUrl) =>
|
||||
$"btcmaps-submit:url={normalizedUrl}";
|
||||
|
||||
public static string NormalizeUrl(string url) =>
|
||||
url.Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
public static string Slugify(string input)
|
||||
{
|
||||
var chars = new StringBuilder();
|
||||
var lastWasDash = true;
|
||||
foreach (var c in input.ToLowerInvariant())
|
||||
{
|
||||
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
|
||||
{
|
||||
chars.Append(c);
|
||||
lastWasDash = false;
|
||||
}
|
||||
else if (!lastWasDash)
|
||||
{
|
||||
chars.Append('-');
|
||||
lastWasDash = true;
|
||||
}
|
||||
}
|
||||
var result = chars.ToString().Trim('-');
|
||||
if (result.Length > 40) result = result[..40].TrimEnd('-');
|
||||
return result.Length == 0 ? "merchant" : result;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, CancellationToken ct)
|
||||
{
|
||||
using var response = await client.GetAsync(path, ct);
|
||||
await EnsureSuccess(response, path, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, object body, CancellationToken ct)
|
||||
{
|
||||
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync(path, content, ct);
|
||||
await EnsureSuccess(response, path, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PutJsonAsync(HttpClient client, string path, object body, CancellationToken ct)
|
||||
{
|
||||
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
using var response = await client.PutAsync(path, content, ct);
|
||||
await EnsureSuccess(response, path, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccess(HttpResponseMessage response, string path, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {body}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user