Skip to content

Commit 98b0b92

Browse files
authored
Expose API error metadata (#52)
1 parent fee650e commit 98b0b92

6 files changed

Lines changed: 212 additions & 3 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,124 @@ jobs:
6363
user: ${{ secrets.NUGET_USER }}
6464

6565
- name: Publish to NuGet
66+
id: publish_step
6667
if: steps.version-check.outputs.exists == 'false'
6768
run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
69+
70+
- name: Notify Slack (success)
71+
if: steps.version-check.outputs.exists == 'false' && steps.publish_step.outcome == 'success'
72+
env:
73+
SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }}
74+
VERSION: ${{ steps.version-check.outputs.version }}
75+
shell: bash
76+
run: |
77+
set -euo pipefail
78+
if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then
79+
echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification"
80+
exit 0
81+
fi
82+
83+
node <<'JS'
84+
const fs = require('node:fs');
85+
86+
const version = process.env.VERSION;
87+
let latestChanges = 'See CHANGELOG for details.';
88+
89+
if (fs.existsSync('CHANGELOG.md')) {
90+
const lines = fs.readFileSync('CHANGELOG.md', 'utf8').split(/\r?\n/);
91+
const bullets = [];
92+
let inTargetSection = false;
93+
for (const line of lines) {
94+
if (line.startsWith(`## [${version}]`)) {
95+
inTargetSection = true;
96+
continue;
97+
}
98+
if (inTargetSection && line.startsWith('## [')) break;
99+
if (inTargetSection && line.startsWith('- ')) {
100+
bullets.push(line.slice(2).trim());
101+
}
102+
}
103+
if (bullets.length) {
104+
latestChanges = bullets.slice(0, 5).map((bullet) => `• ${bullet}`).join('\n');
105+
}
106+
}
107+
108+
fs.writeFileSync('/tmp/slack_payload.json', JSON.stringify({
109+
text: `Facturapi .NET SDK ${version} published to NuGet`,
110+
blocks: [
111+
{
112+
type: 'header',
113+
text: {
114+
type: 'plain_text',
115+
text: `.NET SDK ${version} published to NuGet`,
116+
},
117+
},
118+
{
119+
type: 'section',
120+
fields: [
121+
{ type: 'mrkdwn', text: `*Package:* \`Facturapi ${version}\`` },
122+
{ type: 'mrkdwn', text: `*Branch:* \`${process.env.GITHUB_REF_NAME}\`` },
123+
{ type: 'mrkdwn', text: `*Commit:* \`${process.env.GITHUB_SHA}\`` },
124+
{ type: 'mrkdwn', text: `*Actor:* \`${process.env.GITHUB_ACTOR}\`` },
125+
],
126+
},
127+
{
128+
type: 'section',
129+
text: {
130+
type: 'mrkdwn',
131+
text: [
132+
'*Useful links*',
133+
`• NuGet: <https://www.nuget.org/packages/Facturapi/${version}|View package>`,
134+
`• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`,
135+
`• Changelog: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/blob/${process.env.GITHUB_SHA}/CHANGELOG.md|Read changes>`,
136+
].join('\n'),
137+
},
138+
},
139+
{
140+
type: 'section',
141+
text: {
142+
type: 'mrkdwn',
143+
text: `*Latest changes*\n${latestChanges}`,
144+
},
145+
},
146+
],
147+
}));
148+
JS
149+
150+
curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true
151+
152+
- name: Notify Slack (failure)
153+
if: failure() && steps.version-check.outputs.exists == 'false' && steps.publish_step.outcome == 'failure'
154+
env:
155+
SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }}
156+
VERSION: ${{ steps.version-check.outputs.version }}
157+
shell: bash
158+
run: |
159+
set -euo pipefail
160+
if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then
161+
echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification"
162+
exit 0
163+
fi
164+
165+
node <<'JS'
166+
const fs = require('node:fs');
167+
168+
fs.writeFileSync('/tmp/slack_payload_failure.json', JSON.stringify({
169+
text: `Facturapi .NET SDK ${process.env.VERSION} publish failed`,
170+
blocks: [
171+
{
172+
type: 'section',
173+
text: {
174+
type: 'mrkdwn',
175+
text: [
176+
`*.NET SDK ${process.env.VERSION} publish failed*`,
177+
`• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`,
178+
`• Commit: \`${process.env.GITHUB_SHA}\``,
179+
].join('\n'),
180+
},
181+
},
182+
],
183+
}));
184+
JS
185+
186+
curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [6.5.0] - 2026-06-07
9+
### Added
10+
- Expose structured API error metadata on `FacturapiException`, including `Code`, `Path`, `Location`, `Errors`, `LogId`, and response `Headers`.
11+
812
## [6.4.0]
913
### Added
1014
- Added `facturapi.Receipt.ToInvoiceAsync(Dictionary<string, object> data)` to call `POST /receipts/to-invoice`.

FacturAPIException.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,38 @@
11
using System;
2+
using System.Collections.Generic;
3+
using Newtonsoft.Json.Linq;
24

35
namespace Facturapi
46
{
57
public class FacturapiException : Exception
68
{
79
public int? Status { get; private set; }
10+
public string Code { get; private set; }
11+
public string Path { get; private set; }
12+
public string Location { get; private set; }
13+
public JArray Errors { get; private set; }
14+
public string LogId { get; private set; }
15+
public IReadOnlyDictionary<string, string> Headers { get; private set; }
816

917
public FacturapiException() : base() { }
10-
public FacturapiException(string message, int? status = null) : base(message)
18+
public FacturapiException(
19+
string message,
20+
int? status = null,
21+
string code = null,
22+
string path = null,
23+
string location = null,
24+
JArray errors = null,
25+
string logId = null,
26+
IReadOnlyDictionary<string, string> headers = null
27+
) : base(message)
1128
{
1229
Status = status;
30+
Code = code;
31+
Path = path;
32+
Location = location;
33+
Errors = errors;
34+
LogId = logId;
35+
Headers = headers ?? new Dictionary<string, string>();
1336
}
1437
}
1538
}

FacturapiTest/WrapperBehaviorTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,37 @@ public async Task ErrorMapping_UsesStatusFromString()
281281
Assert.Equal("bad request", exception.Message);
282282
}
283283

284+
[Fact]
285+
public async Task ErrorMapping_ExposesApiErrorFieldsAndHeaders()
286+
{
287+
var handler = new RecordingHandler((request, cancellationToken) =>
288+
{
289+
var response = new HttpResponseMessage((HttpStatusCode)429)
290+
{
291+
Content = new StringContent(
292+
"{\"message\":\"too many requests\",\"status\":429,\"code\":\"RATE_LIMIT_EXCEEDED\",\"path\":\"date\",\"location\":\"query\",\"errors\":[{\"code\":\"required\",\"message\":\"date is required\",\"path\":\"date\",\"location\":\"query\"}]}",
293+
Encoding.UTF8,
294+
"application/json")
295+
};
296+
response.Headers.Add("Retry-After", "3");
297+
response.Headers.Add("x-facturapi-log-id", "log_123");
298+
return Task.FromResult(response);
299+
});
300+
301+
var wrapper = new CustomerWrapper("test_key", "v2", CreateHttpClient(handler));
302+
var exception = await Assert.ThrowsAsync<FacturapiException>(() => wrapper.ListAsync());
303+
304+
Assert.Equal(429, exception.Status);
305+
Assert.Equal("too many requests", exception.Message);
306+
Assert.Equal("RATE_LIMIT_EXCEEDED", exception.Code);
307+
Assert.Equal("date", exception.Path);
308+
Assert.Equal("query", exception.Location);
309+
Assert.Equal("log_123", exception.LogId);
310+
Assert.Equal("required", exception.Errors[0]["code"]?.ToString());
311+
Assert.Equal("3", exception.Headers["retry-after"]);
312+
Assert.Equal("log_123", exception.Headers["x-facturapi-log-id"]);
313+
}
314+
284315
[Fact]
285316
public async Task ErrorMapping_UsesStatusFromFloat()
286317
{

Wrappers/BaseWrapper.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Newtonsoft.Json;
22
using Newtonsoft.Json.Linq;
33
using System;
4+
using System.Collections.Generic;
45
using System.Net.Http;
56
using System.Text;
67
using System.Threading.Tasks;
@@ -67,7 +68,38 @@ protected FacturapiException CreateException(string resultString, HttpResponseMe
6768
status = (int)response.StatusCode;
6869
}
6970

70-
return new FacturapiException(message, status);
71+
var headers = NormalizeResponseHeaders(response);
72+
return new FacturapiException(
73+
message,
74+
status,
75+
error?["code"]?.Type == JTokenType.String ? error["code"]?.ToString() : null,
76+
error?["path"]?.Type == JTokenType.String ? error["path"]?.ToString() : null,
77+
error?["location"]?.Type == JTokenType.String ? error["location"]?.ToString() : null,
78+
error?["errors"] as JArray,
79+
headers.TryGetValue("x-facturapi-log-id", out var logId) ? logId : null,
80+
headers
81+
);
82+
}
83+
84+
private static IReadOnlyDictionary<string, string> NormalizeResponseHeaders(HttpResponseMessage response)
85+
{
86+
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
87+
if (response == null)
88+
{
89+
return headers;
90+
}
91+
foreach (var header in response.Headers)
92+
{
93+
headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value);
94+
}
95+
if (response.Content != null)
96+
{
97+
foreach (var header in response.Content.Headers)
98+
{
99+
headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value);
100+
}
101+
}
102+
return headers;
71103
}
72104

73105
protected async Task ThrowIfErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)

facturapi-net.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<Summary>SDK oficial de Facturapi para .NET para facturación electrónica en México (CFDI), envío de documentos, búsqueda y trazabilidad.</Summary>
1212
<PackageTags>factura factura-electronica facturacion cfdi cfdi40 sat invoice invoicing facturapi mexico</PackageTags>
1313
<Title>Facturapi</Title>
14-
<Version>6.4.0</Version>
14+
<Version>6.5.0</Version>
1515
<PackageVersion>$(Version)</PackageVersion>
1616
<PackageLicenseExpression>MIT</PackageLicenseExpression>
1717
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>

0 commit comments

Comments
 (0)