diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7c52a04..f66da17 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,5 +63,124 @@ jobs: user: ${{ secrets.NUGET_USER }} - name: Publish to NuGet + id: publish_step if: steps.version-check.outputs.exists == 'false' run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + + - name: Notify Slack (success) + if: steps.version-check.outputs.exists == 'false' && steps.publish_step.outcome == 'success' + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + VERSION: ${{ steps.version-check.outputs.version }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + + node <<'JS' + const fs = require('node:fs'); + + const version = process.env.VERSION; + let latestChanges = 'See CHANGELOG for details.'; + + if (fs.existsSync('CHANGELOG.md')) { + const lines = fs.readFileSync('CHANGELOG.md', 'utf8').split(/\r?\n/); + const bullets = []; + let inTargetSection = false; + for (const line of lines) { + if (line.startsWith(`## [${version}]`)) { + inTargetSection = true; + continue; + } + if (inTargetSection && line.startsWith('## [')) break; + if (inTargetSection && line.startsWith('- ')) { + bullets.push(line.slice(2).trim()); + } + } + if (bullets.length) { + latestChanges = bullets.slice(0, 5).map((bullet) => `• ${bullet}`).join('\n'); + } + } + + fs.writeFileSync('/tmp/slack_payload.json', JSON.stringify({ + text: `Facturapi .NET SDK ${version} published to NuGet`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `.NET SDK ${version} published to NuGet`, + }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Package:* \`Facturapi ${version}\`` }, + { type: 'mrkdwn', text: `*Branch:* \`${process.env.GITHUB_REF_NAME}\`` }, + { type: 'mrkdwn', text: `*Commit:* \`${process.env.GITHUB_SHA}\`` }, + { type: 'mrkdwn', text: `*Actor:* \`${process.env.GITHUB_ACTOR}\`` }, + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + '*Useful links*', + `• NuGet: `, + `• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`, + `• Changelog: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/blob/${process.env.GITHUB_SHA}/CHANGELOG.md|Read changes>`, + ].join('\n'), + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Latest changes*\n${latestChanges}`, + }, + }, + ], + })); + JS + + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true + + - name: Notify Slack (failure) + if: failure() && steps.version-check.outputs.exists == 'false' && steps.publish_step.outcome == 'failure' + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + VERSION: ${{ steps.version-check.outputs.version }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + + node <<'JS' + const fs = require('node:fs'); + + fs.writeFileSync('/tmp/slack_payload_failure.json', JSON.stringify({ + text: `Facturapi .NET SDK ${process.env.VERSION} publish failed`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + `*.NET SDK ${process.env.VERSION} publish failed*`, + `• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`, + `• Commit: \`${process.env.GITHUB_SHA}\``, + ].join('\n'), + }, + }, + ], + })); + JS + + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a703a..b103992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.5.0] - 2026-06-07 +### Added +- Expose structured API error metadata on `FacturapiException`, including `Code`, `Path`, `Location`, `Errors`, `LogId`, and response `Headers`. + ## [6.4.0] ### Added - Added `facturapi.Receipt.ToInvoiceAsync(Dictionary data)` to call `POST /receipts/to-invoice`. diff --git a/FacturAPIException.cs b/FacturAPIException.cs index b91d61c..a87a792 100644 --- a/FacturAPIException.cs +++ b/FacturAPIException.cs @@ -1,15 +1,38 @@ using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; namespace Facturapi { public class FacturapiException : Exception { public int? Status { get; private set; } + public string Code { get; private set; } + public string Path { get; private set; } + public string Location { get; private set; } + public JArray Errors { get; private set; } + public string LogId { get; private set; } + public IReadOnlyDictionary Headers { get; private set; } public FacturapiException() : base() { } - public FacturapiException(string message, int? status = null) : base(message) + public FacturapiException( + string message, + int? status = null, + string code = null, + string path = null, + string location = null, + JArray errors = null, + string logId = null, + IReadOnlyDictionary headers = null + ) : base(message) { Status = status; + Code = code; + Path = path; + Location = location; + Errors = errors; + LogId = logId; + Headers = headers ?? new Dictionary(); } } } diff --git a/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index 9080889..f03fddb 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -281,6 +281,37 @@ public async Task ErrorMapping_UsesStatusFromString() Assert.Equal("bad request", exception.Message); } + [Fact] + public async Task ErrorMapping_ExposesApiErrorFieldsAndHeaders() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + var response = new HttpResponseMessage((HttpStatusCode)429) + { + Content = new StringContent( + "{\"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\"}]}", + Encoding.UTF8, + "application/json") + }; + response.Headers.Add("Retry-After", "3"); + response.Headers.Add("x-facturapi-log-id", "log_123"); + return Task.FromResult(response); + }); + + var wrapper = new CustomerWrapper("test_key", "v2", CreateHttpClient(handler)); + var exception = await Assert.ThrowsAsync(() => wrapper.ListAsync()); + + Assert.Equal(429, exception.Status); + Assert.Equal("too many requests", exception.Message); + Assert.Equal("RATE_LIMIT_EXCEEDED", exception.Code); + Assert.Equal("date", exception.Path); + Assert.Equal("query", exception.Location); + Assert.Equal("log_123", exception.LogId); + Assert.Equal("required", exception.Errors[0]["code"]?.ToString()); + Assert.Equal("3", exception.Headers["retry-after"]); + Assert.Equal("log_123", exception.Headers["x-facturapi-log-id"]); + } + [Fact] public async Task ErrorMapping_UsesStatusFromFloat() { diff --git a/Wrappers/BaseWrapper.cs b/Wrappers/BaseWrapper.cs index 88992d1..0871265 100644 --- a/Wrappers/BaseWrapper.cs +++ b/Wrappers/BaseWrapper.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Threading.Tasks; @@ -67,7 +68,38 @@ protected FacturapiException CreateException(string resultString, HttpResponseMe status = (int)response.StatusCode; } - return new FacturapiException(message, status); + var headers = NormalizeResponseHeaders(response); + return new FacturapiException( + message, + status, + error?["code"]?.Type == JTokenType.String ? error["code"]?.ToString() : null, + error?["path"]?.Type == JTokenType.String ? error["path"]?.ToString() : null, + error?["location"]?.Type == JTokenType.String ? error["location"]?.ToString() : null, + error?["errors"] as JArray, + headers.TryGetValue("x-facturapi-log-id", out var logId) ? logId : null, + headers + ); + } + + private static IReadOnlyDictionary NormalizeResponseHeaders(HttpResponseMessage response) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (response == null) + { + return headers; + } + foreach (var header in response.Headers) + { + headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value); + } + if (response.Content != null) + { + foreach (var header in response.Content.Headers) + { + headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value); + } + } + return headers; } protected async Task ThrowIfErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) diff --git a/facturapi-net.csproj b/facturapi-net.csproj index 1ed4cdd..5a243c5 100644 --- a/facturapi-net.csproj +++ b/facturapi-net.csproj @@ -11,7 +11,7 @@ SDK oficial de Facturapi para .NET para facturación electrónica en México (CFDI), envío de documentos, búsqueda y trazabilidad. factura factura-electronica facturacion cfdi cfdi40 sat invoice invoicing facturapi mexico Facturapi - 6.4.0 + 6.5.0 $(Version) MIT false