| | | 1 | | using System.Net; |
| | | 2 | | using System.Net.Http.Headers; |
| | | 3 | | using System.Net.Http.Json; |
| | | 4 | | using System.Text.Json; |
| | | 5 | | using ShopGuard.Core.Models; |
| | | 6 | | |
| | | 7 | | namespace ShopGuard.Core.Api; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Typed client for the restful-booker API (https://restful-booker.herokuapp.com). |
| | | 11 | | /// The HttpClient is injected so tests can mock the message handler; |
| | | 12 | | /// its BaseAddress must point to the API root. |
| | | 13 | | /// </summary> |
| | | 14 | | public sealed class BookingApiClient |
| | | 15 | | { |
| | 1 | 16 | | private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); |
| | | 17 | | |
| | | 18 | | private readonly HttpClient httpClient; |
| | | 19 | | |
| | 15 | 20 | | public BookingApiClient(HttpClient httpClient) |
| | | 21 | | { |
| | 15 | 22 | | this.httpClient = httpClient; |
| | | 23 | | // restful-booker answers 418 "I'm a Teapot" when no Accept header is sent. |
| | 15 | 24 | | if (!httpClient.DefaultRequestHeaders.Accept.Any()) |
| | | 25 | | { |
| | 15 | 26 | | httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); |
| | | 27 | | } |
| | 15 | 28 | | } |
| | | 29 | | |
| | | 30 | | /// <summary>Auth token used as cookie for write operations (PUT/PATCH/DELETE).</summary> |
| | 15 | 31 | | public string? Token { get; private set; } |
| | | 32 | | |
| | | 33 | | /// <summary> |
| | | 34 | | /// Requests a token via POST /auth. The API answers HTTP 200 even for bad |
| | | 35 | | /// credentials and signals failure through a "reason" field instead. |
| | | 36 | | /// </summary> |
| | | 37 | | /// <returns>The token, or null when the credentials were rejected.</returns> |
| | | 38 | | public async Task<string?> AuthenticateAsync(string username, string password, CancellationToken ct = default) |
| | | 39 | | { |
| | 6 | 40 | | var response = await httpClient.PostAsJsonAsync( |
| | 6 | 41 | | "auth", new AuthRequest { Username = username, Password = password }, JsonOptions, ct); |
| | 6 | 42 | | await EnsureSuccessAsync(response, ct); |
| | | 43 | | |
| | 6 | 44 | | var auth = await ReadAsAsync<AuthResponse>(response, ct); |
| | 6 | 45 | | Token = auth.Token; |
| | 6 | 46 | | return auth.Token; |
| | 6 | 47 | | } |
| | | 48 | | |
| | | 49 | | /// <summary>Checks API availability via GET /ping (returns 201 by contract).</summary> |
| | | 50 | | public async Task<bool> PingAsync(CancellationToken ct = default) |
| | | 51 | | { |
| | 2 | 52 | | var response = await httpClient.GetAsync("ping", ct); |
| | 2 | 53 | | return response.StatusCode == HttpStatusCode.Created; |
| | 2 | 54 | | } |
| | | 55 | | |
| | | 56 | | /// <summary>Returns all booking ids via GET /booking.</summary> |
| | | 57 | | public async Task<IReadOnlyList<int>> GetBookingIdsAsync(CancellationToken ct = default) |
| | | 58 | | { |
| | 2 | 59 | | var response = await httpClient.GetAsync("booking", ct); |
| | 2 | 60 | | await EnsureSuccessAsync(response, ct); |
| | | 61 | | |
| | 2 | 62 | | var ids = await ReadAsAsync<List<BookingId>>(response, ct); |
| | 5 | 63 | | return ids.Select(b => b.Id).ToList(); |
| | 2 | 64 | | } |
| | | 65 | | |
| | | 66 | | /// <summary>Returns a single booking via GET /booking/{id}, or null when it does not exist.</summary> |
| | | 67 | | public async Task<Booking?> GetBookingAsync(int bookingId, CancellationToken ct = default) |
| | | 68 | | { |
| | 2 | 69 | | var response = await httpClient.GetAsync($"booking/{bookingId}", ct); |
| | 2 | 70 | | if (response.StatusCode == HttpStatusCode.NotFound) |
| | | 71 | | { |
| | 1 | 72 | | return null; |
| | | 73 | | } |
| | | 74 | | |
| | 1 | 75 | | await EnsureSuccessAsync(response, ct); |
| | 1 | 76 | | return await ReadAsAsync<Booking>(response, ct); |
| | 2 | 77 | | } |
| | | 78 | | |
| | | 79 | | /// <summary>Creates a booking via POST /booking.</summary> |
| | | 80 | | public async Task<BookingCreatedResponse> CreateBookingAsync(Booking booking, CancellationToken ct = default) |
| | | 81 | | { |
| | 2 | 82 | | var response = await httpClient.PostAsJsonAsync("booking", booking, JsonOptions, ct); |
| | 2 | 83 | | await EnsureSuccessAsync(response, ct); |
| | 1 | 84 | | return await ReadAsAsync<BookingCreatedResponse>(response, ct); |
| | 1 | 85 | | } |
| | | 86 | | |
| | | 87 | | /// <summary>Replaces a booking via PUT /booking/{id}. Requires a prior <see cref="AuthenticateAsync"/>.</summary> |
| | | 88 | | public async Task<Booking> UpdateBookingAsync(int bookingId, Booking booking, CancellationToken ct = default) |
| | | 89 | | { |
| | 2 | 90 | | using var request = new HttpRequestMessage(HttpMethod.Put, $"booking/{bookingId}") |
| | 2 | 91 | | { |
| | 2 | 92 | | Content = JsonContent.Create(booking, options: JsonOptions), |
| | 2 | 93 | | }; |
| | 2 | 94 | | AddAuthCookie(request); |
| | | 95 | | |
| | 1 | 96 | | var response = await httpClient.SendAsync(request, ct); |
| | 1 | 97 | | await EnsureSuccessAsync(response, ct); |
| | 1 | 98 | | return await ReadAsAsync<Booking>(response, ct); |
| | 1 | 99 | | } |
| | | 100 | | |
| | | 101 | | /// <summary>Deletes a booking via DELETE /booking/{id}. Requires a prior <see cref="AuthenticateAsync"/>.</summary> |
| | | 102 | | public async Task DeleteBookingAsync(int bookingId, CancellationToken ct = default) |
| | | 103 | | { |
| | 2 | 104 | | using var request = new HttpRequestMessage(HttpMethod.Delete, $"booking/{bookingId}"); |
| | 2 | 105 | | AddAuthCookie(request); |
| | | 106 | | |
| | 2 | 107 | | var response = await httpClient.SendAsync(request, ct); |
| | | 108 | | // The API answers 201 Created on successful delete — another documented quirk. |
| | 2 | 109 | | if (response.StatusCode is not (HttpStatusCode.Created or HttpStatusCode.OK)) |
| | | 110 | | { |
| | 1 | 111 | | throw await ToApiExceptionAsync(response, ct); |
| | | 112 | | } |
| | 1 | 113 | | } |
| | | 114 | | |
| | | 115 | | /// <summary>Sends a raw status-code probe; used by negative tests that bypass the typed methods.</summary> |
| | | 116 | | public Task<HttpResponseMessage> SendRawAsync(HttpRequestMessage request, CancellationToken ct = default) |
| | 0 | 117 | | => httpClient.SendAsync(request, ct); |
| | | 118 | | |
| | | 119 | | private void AddAuthCookie(HttpRequestMessage request) |
| | | 120 | | { |
| | 4 | 121 | | if (Token is null) |
| | | 122 | | { |
| | 1 | 123 | | throw new InvalidOperationException( |
| | 1 | 124 | | "No auth token available. Call AuthenticateAsync before write operations."); |
| | | 125 | | } |
| | | 126 | | |
| | 3 | 127 | | request.Headers.Add("Cookie", $"token={Token}"); |
| | 3 | 128 | | } |
| | | 129 | | |
| | | 130 | | private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) |
| | | 131 | | { |
| | 12 | 132 | | if (!response.IsSuccessStatusCode) |
| | | 133 | | { |
| | 1 | 134 | | throw await ToApiExceptionAsync(response, ct); |
| | | 135 | | } |
| | 11 | 136 | | } |
| | | 137 | | |
| | | 138 | | private static async Task<ApiException> ToApiExceptionAsync(HttpResponseMessage response, CancellationToken ct) |
| | | 139 | | { |
| | 2 | 140 | | var body = await response.Content.ReadAsStringAsync(ct); |
| | 2 | 141 | | return new ApiException( |
| | 2 | 142 | | response.StatusCode, |
| | 2 | 143 | | body, |
| | 2 | 144 | | $"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri} failed with {(int)response.StatusC |
| | 2 | 145 | | } |
| | | 146 | | |
| | | 147 | | private static async Task<T> ReadAsAsync<T>(HttpResponseMessage response, CancellationToken ct) |
| | | 148 | | { |
| | 11 | 149 | | var result = await response.Content.ReadFromJsonAsync<T>(JsonOptions, ct); |
| | 11 | 150 | | return result ?? throw new ApiException( |
| | 11 | 151 | | response.StatusCode, null, "Response body was empty or could not be deserialized."); |
| | 11 | 152 | | } |
| | | 153 | | } |