From d01797926221e355beb7d7f489d87dce2d043254 Mon Sep 17 00:00:00 2001 From: Andrew Tarr Date: Mon, 11 Aug 2025 19:08:12 -0500 Subject: [PATCH 1/7] Filling out the search option query models to be more real and clean --- .../GuardianApiClientTests.cs | 4 +- .../GuardianClient/GuardianApiClient.cs | 92 +- .../GuardianClient/GuardianApiOptions.cs | 10 - .../GuardianClient/Models/BaseResponse.cs | 11 +- .../GuardianClient/Models/ContentItem.cs | 20 +- .../Models/ContentSearchResponse.cs | 22 +- ...nApiContentAdditionalInformationOptions.cs | 32 + .../Search/GuardianApiContentDateOptions.cs | 25 + .../Search/GuardianApiContentFilterOptions.cs | 61 + .../Search/GuardianApiContentOrderOptions.cs | 17 + .../Search/GuardianApiContentPageOptions.cs | 19 + .../Search/GuardianApiContentSearchOptions.cs | 44 + .../Options/Search/GuardianApiOrderBy.cs | 22 + .../Options/Search/GuardianApiOrderDate.cs | 22 + guardian-api-spec.yaml | 1148 ----------------- 15 files changed, 341 insertions(+), 1208 deletions(-) delete mode 100644 GuardianClient/GuardianClient/GuardianApiOptions.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentDateOptions.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiOrderBy.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiOrderDate.cs delete mode 100644 guardian-api-spec.yaml diff --git a/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs b/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs index 357e7c0..fadc4d7 100644 --- a/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs +++ b/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs @@ -1,3 +1,5 @@ +using GuardianClient.Options.Search; + namespace GuardianClient.Tests; using Shouldly; @@ -45,7 +47,7 @@ public async Task GetItemAsyncSmokeTest() var contentItem = searchResult.Results.First(); var itemId = contentItem.Id; - var singleItemResult = await ApiClient.GetItemAsync(itemId, new GuardianApiOptions { ShowFields = ["body"] }); + var singleItemResult = await ApiClient.GetItemAsync(itemId, new GuardianApiContentAdditionalInformationOptions { ShowFields = ["body"] }); singleItemResult.ShouldNotBeNull("GetItem result should not be null"); singleItemResult.Status.ShouldBe("ok", "API response status should be 'ok'"); diff --git a/GuardianClient/GuardianClient/GuardianApiClient.cs b/GuardianClient/GuardianClient/GuardianApiClient.cs index 204b3ff..c070f05 100644 --- a/GuardianClient/GuardianClient/GuardianApiClient.cs +++ b/GuardianClient/GuardianClient/GuardianApiClient.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Text.Json; using GuardianClient.Models; +using GuardianClient.Options.Search; namespace GuardianClient; @@ -72,6 +73,7 @@ private string GetPackageVersion() return packageVersion; } + // TODO comment out of date /// /// Search for Guardian content /// @@ -82,53 +84,53 @@ private string GetPackageVersion() /// Cancellation token /// Content search results public async Task SearchAsync( - string? query = null, - int? pageSize = null, - int? page = null, - GuardianApiOptions? options = null, - CancellationToken cancellationToken = default) + GuardianApiContentSearchOptions? options = null, + CancellationToken cancellationToken = default + ) { - var parameters = new List { $"api-key={Uri.EscapeDataString(_apiKey)}" }; - - if (!string.IsNullOrWhiteSpace(query)) - { - parameters.Add($"q={Uri.EscapeDataString(query)}"); - } - - if (pageSize.HasValue) - { - parameters.Add($"page-size={pageSize.Value}"); - } - - if (page.HasValue) - { - parameters.Add($"page={page.Value}"); - } + options ??= new GuardianApiContentSearchOptions(); - if (options?.ShowFields?.Length > 0) - { - parameters.Add($"show-fields={string.Join(",", options.ShowFields)}"); - } - - if (options?.ShowTags?.Length > 0) - { - parameters.Add($"show-tags={string.Join(",", options.ShowTags)}"); - } - - if (options?.ShowElements?.Length > 0) - { - parameters.Add($"show-elements={string.Join(",", options.ShowElements)}"); - } - - if (options?.ShowReferences?.Length > 0) - { - parameters.Add($"show-references={string.Join(",", options.ShowReferences)}"); - } + var parameters = new List { $"api-key={Uri.EscapeDataString(_apiKey)}" }; - if (options?.ShowBlocks?.Length > 0) - { - parameters.Add($"show-blocks={string.Join(",", options.ShowBlocks)}"); - } + // if (!string.IsNullOrWhiteSpace(query)) + // { + // parameters.Add($"q={Uri.EscapeDataString(query)}"); + // } + // + // if (pageSize.HasValue) + // { + // parameters.Add($"page-size={pageSize.Value}"); + // } + // + // if (page.HasValue) + // { + // parameters.Add($"page={page.Value}"); + // } + // + // if (options?.ShowFields?.Length > 0) + // { + // parameters.Add($"show-fields={string.Join(",", options.ShowFields)}"); + // } + // + // if (options?.ShowTags?.Length > 0) + // { + // parameters.Add($"show-tags={string.Join(",", options.ShowTags)}"); + // } + // + // if (options?.ShowElements?.Length > 0) + // { + // parameters.Add($"show-elements={string.Join(",", options.ShowElements)}"); + // } + // + // if (options?.ShowReferences?.Length > 0) + // { + // parameters.Add($"show-references={string.Join(",", options.ShowReferences)}"); + // } + // + // if (options?.ShowBlocks?.Length > 0) + // { + // parameters.Add($"show-blocks={string.Join(",", options.ShowBlocks)}"); + // } var url = $"/search?{string.Join("&", parameters)}"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -150,7 +152,7 @@ private string GetPackageVersion() /// Single item response with content details public async Task GetItemAsync( string itemId, - GuardianApiOptions? options = null, + GuardianApiContentAdditionalInformationOptions? options = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(itemId); diff --git a/GuardianClient/GuardianClient/GuardianApiOptions.cs b/GuardianClient/GuardianClient/GuardianApiOptions.cs deleted file mode 100644 index 505cc6b..0000000 --- a/GuardianClient/GuardianClient/GuardianApiOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GuardianClient; - -public class GuardianApiOptions -{ - public string[]? ShowFields { get; set; } - public string[]? ShowTags { get; set; } - public string[]? ShowElements { get; set; } - public string[]? ShowReferences { get; set; } - public string[]? ShowBlocks { get; set; } -} diff --git a/GuardianClient/GuardianClient/Models/BaseResponse.cs b/GuardianClient/GuardianClient/Models/BaseResponse.cs index 91a091b..2458949 100644 --- a/GuardianClient/GuardianClient/Models/BaseResponse.cs +++ b/GuardianClient/GuardianClient/Models/BaseResponse.cs @@ -4,12 +4,21 @@ namespace GuardianClient.Models; public class BaseResponse { + /// + /// The status of the response. It refers to the state of the API. Successful calls will receive an "ok" even if your query did not return any results. + /// [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; + /// + /// The user tier for the API account. + /// [JsonPropertyName("userTier")] public string? UserTier { get; set; } + /// + /// The number of results available for your search overall. + /// [JsonPropertyName("total")] public int Total { get; set; } -} \ No newline at end of file +} diff --git a/GuardianClient/GuardianClient/Models/ContentItem.cs b/GuardianClient/GuardianClient/Models/ContentItem.cs index c201d81..6c0aab8 100644 --- a/GuardianClient/GuardianClient/Models/ContentItem.cs +++ b/GuardianClient/GuardianClient/Models/ContentItem.cs @@ -4,27 +4,45 @@ namespace GuardianClient.Models; public class ContentItem { + /// + /// The path to content. + /// [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + /// + /// The id of the section. + /// [JsonPropertyName("sectionId")] public string? SectionId { get; set; } + /// + /// The name of the section. + /// [JsonPropertyName("sectionName")] public string? SectionName { get; set; } + /// + /// The combined date and time of publication. + /// [JsonPropertyName("webPublicationDate")] public DateTime? WebPublicationDate { get; set; } [JsonPropertyName("webTitle")] public string WebTitle { get; set; } = string.Empty; + /// + /// The URL of the html content. + /// [JsonPropertyName("webUrl")] public string WebUrl { get; set; } = string.Empty; + /// + /// The URL of the raw content. + /// [JsonPropertyName("apiUrl")] public string ApiUrl { get; set; } = string.Empty; @@ -51,4 +69,4 @@ public class ContentItem [JsonPropertyName("blocks")] public ContentBlocks? Blocks { get; set; } -} \ No newline at end of file +} diff --git a/GuardianClient/GuardianClient/Models/ContentSearchResponse.cs b/GuardianClient/GuardianClient/Models/ContentSearchResponse.cs index 45b4654..399ea7f 100644 --- a/GuardianClient/GuardianClient/Models/ContentSearchResponse.cs +++ b/GuardianClient/GuardianClient/Models/ContentSearchResponse.cs @@ -4,21 +4,39 @@ namespace GuardianClient.Models; public class ContentSearchResponse : BaseResponse { + /// + /// The starting index for the current result set. + /// [JsonPropertyName("startIndex")] public int StartIndex { get; set; } + /// + /// The number of items returned in this call. + /// [JsonPropertyName("pageSize")] public int PageSize { get; set; } + /// + /// The number of the page you are browsing. + /// [JsonPropertyName("currentPage")] public int CurrentPage { get; set; } + /// + /// The total amount of pages that are in this call. + /// [JsonPropertyName("pages")] public int Pages { get; set; } + /// + /// The sort order used. + /// [JsonPropertyName("orderBy")] public string? OrderBy { get; set; } + /// + /// The collection of content items returned by the search. + /// [JsonPropertyName("results")] - public List Results { get; set; } = new(); -} \ No newline at end of file + public ICollection Results { get; set; } = new HashSet(); +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs new file mode 100644 index 0000000..8f37c27 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs @@ -0,0 +1,32 @@ +namespace GuardianClient.Options.Search; + +public class GuardianApiContentAdditionalInformationOptions +{ + /// + /// + /// + public string[]? ShowFields { get; set; } + + /// + /// + /// + public string[]? ShowTags { get; set; } + + /// + /// + /// + public string[]? ShowElements { get; set; } + + /// + /// + /// + public string[]? ShowReferences { get; set; } + + /// + /// + /// + public string[]? ShowBlocks { get; set; } +} + +// TODO additional information table below shows all the options that are available in the Show* methods, so +// we could strongly type those. diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentDateOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentDateOptions.cs new file mode 100644 index 0000000..0fe11f5 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentDateOptions.cs @@ -0,0 +1,25 @@ +namespace GuardianClient.Options.Search; + +/// +/// Options for filtering search results by date ranges. +/// +public class GuardianApiContentDateOptions +{ + /// + /// Return only content published on or after that date. + /// Example: 2014-02-16. + /// + public DateOnly FromDate { get; set; } + + /// + /// Return only content published on or before that date. + /// Example: 2014-02-17. + /// + public DateOnly ToDate { get; set; } + + /// + /// Changes which type of date is used to filter the results using FromDate and ToDate. + /// Accepted values: "published" (default - the date the content has been last published), "first-publication" (the date the content has been first published), "newspaper-edition" (the date the content appeared in print), "last-modified" (the date the content was last updated). + /// + public string? UseDate { get; set; } +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs new file mode 100644 index 0000000..220b3e9 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs @@ -0,0 +1,61 @@ +namespace GuardianClient.Options.Search; + +/// +/// Options for filtering content search results by various criteria. +/// +public class GuardianApiContentFilterOptions +{ + /// + /// Return only content in those sections. Supports boolean operators. + /// Example: "football". + /// + public string? Section { get; set; } + + /// + /// Return only content with those references. Supports boolean operators. + /// Example: "isbn/9780718178949". + /// + public string? Reference { get; set; } + + /// + /// Return only content with references of those types. Supports boolean operators. + /// Example: "isbn". + /// + public string? ReferenceType { get; set; } + + /// + /// Return only content with those tags. Supports boolean operators. + /// Example: "technology/apple". + /// + public string? Tag { get; set; } + + /// + /// Return only content with those rights. Does not support boolean operators. + /// Accepted values: "syndicatable", "subscription-databases". + /// + public string? Rights { get; set; } + + /// + /// Return only content with those IDs. Does not support boolean operators. + /// Example: "technology/2014/feb/17/flappy-bird-clones-apple-google". + /// + public string? Ids { get; set; } + + /// + /// Return only content from those production offices. Supports boolean operators. + /// Example: "aus". + /// + public string? ProductionOffice { get; set; } + + /// + /// Return only content in those languages. Supports boolean operators. + /// Accepts ISO language codes. Examples: "en", "fr". + /// + public string? Language { get; set; } + + /// + /// Return only content with a given star rating. Does not support boolean operators. + /// Accepted values: 1 to 5. + /// + public int? StarRating { get; set; } +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs new file mode 100644 index 0000000..09e960c --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs @@ -0,0 +1,17 @@ +namespace GuardianClient.Options.Search; + +/// +/// Options for controlling the ordering of search results. +/// +public class GuardianApiContentOrderOptions +{ + /// + /// Returns results in the specified order. Defaults to Newest in most cases, or Relevance when a query parameter is specified. + /// + public GuardianApiOrderBy? OrderBy { get; set; } + + /// + /// Changes which type of date is used to order the results. Defaults to Published. + /// + public GuardianApiOrderDate OrderDate { get; set; } +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs new file mode 100644 index 0000000..df86b78 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs @@ -0,0 +1,19 @@ +namespace GuardianClient.Options.Search; + +/// +/// Options for controlling pagination of search results. +/// +public class GuardianApiContentPageOptions +{ + /// + /// Return only the result set from a particular page. + /// Example: 5. + /// + public int Page { get; set; } + + /// + /// Modify the number of items displayed per page. + /// Accepted values: 1 to 50. + /// + public int PageSize { get; set; } +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs new file mode 100644 index 0000000..49c7768 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs @@ -0,0 +1,44 @@ +namespace GuardianClient.Options.Search; + +/// +/// Options for searching content using the Guardian API. +/// +public class GuardianApiContentSearchOptions +{ + /// + /// Request content containing this free text. Supports AND, OR and NOT operators, and exact phrase queries using double quotes. + /// Examples: "sausages", "pork sausages", "sausages AND (mash OR chips)", "sausages AND NOT (saveloy OR battered)". + /// + public string? Query { get; set; } + + /// + /// Specify in which indexed fields query terms should be searched on. + /// Examples: ["body"], ["body", "thumbnail"]. + /// + public string[]? QueryFields { get; set; } + + /// + /// Options for requesting additional information to be included with search results. + /// + public GuardianApiContentAdditionalInformationOptions AdditionalInformationOptions { get; set; } = new(); + + /// + /// Options for filtering search results by various criteria. + /// + public GuardianApiContentFilterOptions FilterOptions { get; set; } = new(); + + /// + /// Options for filtering search results by date ranges. + /// + public GuardianApiContentDateOptions DateOptions { get; set; } = new(); + + /// + /// Options for controlling pagination of search results. + /// + public GuardianApiContentPageOptions PageOptions { get; set; } = new(); + + /// + /// Options for controlling the ordering of search results. + /// + public GuardianApiContentOrderOptions OrderOptions { get; set; } = new(); +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderBy.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderBy.cs new file mode 100644 index 0000000..f5ee5a8 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderBy.cs @@ -0,0 +1,22 @@ +namespace GuardianClient.Options.Search; + +/// +/// Specifies the order in which search results should be returned. +/// +public enum GuardianApiOrderBy +{ + /// + /// Order by newest content first. Default in all other cases. + /// + Newest, + + /// + /// Order by oldest content first. + /// + Oldest, + + /// + /// Order by relevance to search query. Default where q parameter is specified. + /// + Relevance +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderDate.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderDate.cs new file mode 100644 index 0000000..31d89e6 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderDate.cs @@ -0,0 +1,22 @@ +namespace GuardianClient.Options.Search; + +/// +/// Specifies which type of date is used to order the results. +/// +public enum GuardianApiOrderDate +{ + /// + /// The date the content appeared on the web. Default. + /// + Published, + + /// + /// The date the content appeared in print. + /// + NewspaperEdition, + + /// + /// The date the content was last updated. + /// + LastModified +} diff --git a/guardian-api-spec.yaml b/guardian-api-spec.yaml deleted file mode 100644 index c92beec..0000000 --- a/guardian-api-spec.yaml +++ /dev/null @@ -1,1148 +0,0 @@ -openapi: 3.0.3 -info: - title: Guardian Content API - description: | - The Guardian Content API provides access to Guardian news content, tags, sections, and editions. - - ## Authentication - All requests require an API key passed as a query parameter: `api-key` - - ## Rate Limiting - The API is rate-limited. For elevated limits, please contact The Guardian. - - ## HTTPS Support - Available at https://content.guardianapis.com/ - version: "1.0.0" - contact: - name: Guardian API Support - url: https://groups.google.com/group/guardian-api-talk/ - license: - name: Guardian API Terms - url: https://open-platform.theguardian.com/access/ - -servers: - - url: https://content.guardianapis.com - description: Production server - -security: - - ApiKeyAuth: [] - -paths: - /search: - get: - summary: Search for content - description: Returns all pieces of content in the API with optional filtering - operationId: searchContent - tags: - - Content - parameters: - - $ref: '#/components/parameters/ApiKey' - - name: q - in: query - description: | - Search query supporting AND, OR, NOT operators and exact phrase queries using double quotes. - Examples: "debate", "debate AND economy", "debate AND NOT immigration", "debate AND (economy OR immigration)" - schema: - type: string - example: "debate AND economy" - - name: query-fields - in: query - description: Specify which indexed fields to search in - schema: - type: array - items: - type: string - enum: [body, thumbnail] - style: form - explode: false - example: ["body", "thumbnail"] - - name: section - in: query - description: Return only content in specified sections (supports boolean operators) - schema: - type: string - example: "football" - - name: reference - in: query - description: Return only content with specified references - schema: - type: string - example: "isbn/9780718178949" - - name: reference-type - in: query - description: Return only content with references of specified types - schema: - type: string - example: "isbn" - - name: tag - in: query - description: Return only content with specified tags (supports boolean operators) - schema: - type: string - example: "technology/apple" - - name: rights - in: query - description: Return only content with specified rights - schema: - type: string - enum: [syndicatable, subscription-databases] - - name: ids - in: query - description: Return only content with specified IDs - schema: - type: string - example: "technology/2014/feb/17/flappy-bird-clones-apple-google" - - name: production-office - in: query - description: Return only content from specified production offices - schema: - type: string - example: "aus" - - name: lang - in: query - description: Return only content in specified languages (ISO language codes) - schema: - type: string - example: "en" - - name: star-rating - in: query - description: Return only content with specified star rating - schema: - type: integer - minimum: 1 - maximum: 5 - - name: from-date - in: query - description: Return only content published on or after this date - schema: - type: string - format: date - example: "2014-02-16" - - name: to-date - in: query - description: Return only content published on or before this date - schema: - type: string - format: date - example: "2014-02-17" - - name: use-date - in: query - description: Type of date to use for filtering - schema: - type: string - enum: [published, first-publication, newspaper-edition, last-modified] - default: published - - name: page - in: query - description: Page number for pagination - schema: - type: integer - minimum: 1 - default: 1 - - name: page-size - in: query - description: Number of items per page - schema: - type: integer - minimum: 1 - maximum: 50 - default: 10 - - name: order-by - in: query - description: Sort order for results - schema: - type: string - enum: [newest, oldest, relevance] - default: newest - - name: order-date - in: query - description: Type of date to use for ordering - schema: - type: string - enum: [published, newspaper-edition, last-modified] - default: published - - name: show-fields - in: query - description: Additional fields to include with content - schema: - type: array - items: - type: string - enum: [ - trailText, headline, showInRelatedContent, body, lastModified, - hasStoryPackage, score, standfirst, shortUrl, thumbnail, wordcount, - commentable, isPremoderated, allowUgc, byline, publication, - internalPageCode, productionOffice, shouldHideAdverts, liveBloggingNow, - commentCloseDate, starRating, all - ] - style: form - explode: false - - name: show-tags - in: query - description: Types of tags to include - schema: - type: array - items: - type: string - enum: [ - blog, contributor, keyword, newspaper-book, newspaper-book-section, - publication, series, tone, type, all - ] - style: form - explode: false - - name: show-section - in: query - description: Include section metadata - schema: - type: boolean - - name: show-blocks - in: query - description: | - Include content blocks. Supports: - - main, body, all - - body:latest (default 20), body:latest:10 (limit 10) - - body:oldest, body:oldest:10 - - body: (specific block) - - body:around: (block + 20 around it) - - body:around::10 (block + 10 around it) - - body:key-events - - body:published-since: - schema: - type: array - items: - type: string - style: form - explode: false - - name: show-elements - in: query - description: Include media elements - schema: - type: array - items: - type: string - enum: [audio, image, video, all] - style: form - explode: false - - name: show-references - in: query - description: Include reference data - schema: - type: array - items: - type: string - enum: [ - author, bisac-prefix, esa-cricket-match, esa-football-match, - esa-football-team, esa-football-tournament, isbn, imdb, musicbrainz, - musicbrainzgenre, opta-cricket-match, opta-football-match, - opta-football-team, opta-football-tournament, pa-football-competition, - pa-football-match, pa-football-team, r1-film, reuters-index-ric, - reuters-stock-ric, witness-assignment - ] - style: form - explode: false - - name: show-rights - in: query - description: Include rights information - schema: - type: array - items: - type: string - enum: [syndicatable, subscription-databases, all] - style: form - explode: false - - name: format - in: query - description: Response format - schema: - type: string - enum: [json] - default: json - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/ContentSearchResponse' - '400': - description: Bad request - '401': - description: Unauthorized - invalid API key - '429': - description: Rate limit exceeded - - /content/{itemId}/next: - get: - summary: Get next page of content for deep pagination - description: | - For deep pagination beyond standard page limits. Use the ID of the last content item - from a previous search to get the next set of results. - operationId: getNextContent - tags: - - Content - parameters: - - name: itemId - in: path - required: true - description: ID of the last content item from previous results - schema: - type: string - example: "food/2022/jul/04/peperonata-sausages-recipe-rachel-roddy" - - $ref: '#/components/parameters/ApiKey' - - name: q - in: query - description: Original search query (must match original search) - schema: - type: string - - name: page-size - in: query - description: Number of items per page (must match original search) - schema: - type: integer - minimum: 1 - maximum: 50 - default: 10 - - name: order-by - in: query - description: Sort order (must match original search) - schema: - type: string - enum: [newest, oldest, relevance] - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/ContentSearchResponse' - '400': - description: Bad request - '401': - description: Unauthorized - invalid API key - '429': - description: Rate limit exceeded - - /tags: - get: - summary: Search for tags - description: Returns all tags in the API with optional filtering - operationId: searchTags - tags: - - Tags - parameters: - - $ref: '#/components/parameters/ApiKey' - - name: q - in: query - description: Return tags containing this free text - schema: - type: string - example: "sausages" - - name: web-title - in: query - description: Return tags starting with this free text - schema: - type: string - example: "sausa" - - name: type - in: query - description: Return only tags of specified type - schema: - type: string - enum: [keyword, series, contributor, tone, type, blog] - - name: section - in: query - description: Return only tags in specified sections (supports boolean operators) - schema: - type: string - example: "football" - - name: reference - in: query - description: Return only tags with specified references - schema: - type: string - example: "isbn/9780349108391" - - name: reference-type - in: query - description: Return only tags with references of specified types - schema: - type: string - example: "isbn" - - name: page - in: query - description: Page number for pagination - schema: - type: integer - minimum: 1 - default: 1 - - name: page-size - in: query - description: Number of items per page - schema: - type: integer - minimum: 1 - maximum: 50 - default: 10 - - name: show-references - in: query - description: Include reference data - schema: - type: array - items: - type: string - enum: [ - author, bisac-prefix, esa-cricket-match, esa-football-match, - esa-football-team, esa-football-tournament, isbn, imdb, musicbrainz, - musicbrainzgenre, opta-cricket-match, opta-football-match, - opta-football-team, opta-football-tournament, pa-football-competition, - pa-football-match, pa-football-team, r1-film, reuters-index-ric, - reuters-stock-ric, witness-assignment - ] - style: form - explode: false - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/TagsResponse' - '400': - description: Bad request - '401': - description: Unauthorized - invalid API key - '429': - description: Rate limit exceeded - - /sections: - get: - summary: Get all sections - description: Returns all sections in the API - operationId: getSections - tags: - - Sections - parameters: - - $ref: '#/components/parameters/ApiKey' - - name: q - in: query - description: Return sections based on query term - schema: - type: string - example: "business" - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/SectionsResponse' - '400': - description: Bad request - '401': - description: Unauthorized - invalid API key - '429': - description: Rate limit exceeded - - /editions: - get: - summary: Get all editions - description: Returns all editions in the API (regionalized front pages) - operationId: getEditions - tags: - - Editions - parameters: - - $ref: '#/components/parameters/ApiKey' - - name: q - in: query - description: Return editions based on query term - schema: - type: string - example: "UK" - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/EditionsResponse' - '400': - description: Bad request - '401': - description: Unauthorized - invalid API key - '429': - description: Rate limit exceeded - - /{itemPath}: - get: - summary: Get single item by path - description: | - Returns all data for a single item (content, tag, or section) by its path. - The path matches the URL structure on theguardian.com. - operationId: getSingleItem - tags: - - Single Item - parameters: - - name: itemPath - in: path - required: true - description: | - The path to the item. Examples: - - Content: technology/2014/feb/18/doge-such-questions-very-answered - - Tag: world/france - - Section: lifeandstyle - schema: - type: string - example: "technology/2014/feb/18/doge-such-questions-very-answered" - - $ref: '#/components/parameters/ApiKey' - - name: show-story-package - in: query - description: Display related content in same story package - schema: - type: boolean - - name: show-editors-picks - in: query - description: Display editor-chosen content for tags/sections - schema: - type: boolean - - name: show-most-viewed - in: query - description: Display most viewed content - schema: - type: boolean - - name: show-related - in: query - description: Display related content items - schema: - type: boolean - - name: show-fields - in: query - description: Additional fields to include - schema: - type: array - items: - type: string - enum: [ - trailText, headline, showInRelatedContent, body, lastModified, - hasStoryPackage, score, standfirst, shortUrl, thumbnail, wordcount, - commentable, isPremoderated, allowUgc, byline, publication, - internalPageCode, productionOffice, shouldHideAdverts, liveBloggingNow, - commentCloseDate, starRating, all - ] - style: form - explode: false - - name: show-tags - in: query - description: Types of tags to include - schema: - type: array - items: - type: string - enum: [ - blog, contributor, keyword, newspaper-book, newspaper-book-section, - publication, series, tone, type, all - ] - style: form - explode: false - - name: show-sections - in: query - description: Include section metadata - schema: - type: boolean - - name: show-blocks - in: query - description: | - Include content blocks. Supports: - - main, body, all - - body:latest (default 20), body:latest:10 (limit 10) - - body:oldest, body:oldest:10 - - body: (specific block) - - body:around: (block + 20 around it) - - body:around::10 (block + 10 around it) - - body:key-events - - body:published-since: - schema: - type: array - items: - type: string - style: form - explode: false - - name: show-elements - in: query - description: Include media elements - schema: - type: array - items: - type: string - enum: [audio, image, video, all] - style: form - explode: false - - name: show-references - in: query - description: Include reference data - schema: - type: array - items: - type: string - enum: [ - author, bisac-prefix, esa-cricket-match, esa-football-match, - esa-football-team, esa-football-tournament, isbn, imdb, musicbrainz, - musicbrainzgenre, opta-cricket-match, opta-football-match, - opta-football-team, opta-football-tournament, pa-football-competition, - pa-football-match, pa-football-team, r1-film, reuters-index-ric, - reuters-stock-ric, witness-assignment - ] - style: form - explode: false - responses: - '200': - description: Successful response - content: - application/json: - schema: - $ref: '#/components/schemas/SingleItemResponse' - '400': - description: Bad request - '401': - description: Unauthorized - invalid API key - '404': - description: Item not found - '429': - description: Rate limit exceeded - -components: - securitySchemes: - ApiKeyAuth: - type: apiKey - in: query - name: api-key - description: API key for authentication - - parameters: - ApiKey: - name: api-key - in: query - required: true - description: Your Guardian API key - schema: - type: string - example: "test" - - schemas: - BaseResponse: - type: object - properties: - status: - type: string - description: API response status - enum: [ok, error] - example: "ok" - userTier: - type: string - description: User's API tier - example: "developer" - total: - type: integer - description: Total number of results available - example: 1 - required: - - status - - ContentItem: - type: object - properties: - id: - type: string - description: Unique identifier/path to the content - example: "world/2022/oct/21/russia-ukraine-war-latest-what-we-know-on-day-240-of-the-invasion" - type: - type: string - description: Type of content - example: "article" - sectionId: - type: string - description: ID of the section - example: "world" - sectionName: - type: string - description: Name of the section - example: "World news" - webPublicationDate: - type: string - format: date-time - description: Date and time of publication - example: "2022-10-21T14:06:14Z" - webTitle: - type: string - description: Title of the content - example: "Russia-Ukraine war latest: what we know on day 240 of the invasion" - webUrl: - type: string - format: uri - description: URL of the HTML content - example: "https://www.theguardian.com/world/2022/oct/21/russia-ukraine-war-latest-what-we-know-on-day-240-of-the-invasion" - apiUrl: - type: string - format: uri - description: URL of the API content - example: "https://content.guardianapis.com/world/2022/oct/21/russia-ukraine-war-latest-what-we-know-on-day-240-of-the-invasion" - isHosted: - type: boolean - description: Whether the content is hosted - example: false - pillarId: - type: string - description: ID of the pillar - example: "pillar/news" - pillarName: - type: string - description: Name of the pillar - example: "News" - fields: - $ref: '#/components/schemas/ContentFields' - tags: - type: array - items: - $ref: '#/components/schemas/Tag' - elements: - type: array - items: - $ref: '#/components/schemas/Element' - references: - type: array - items: - $ref: '#/components/schemas/Reference' - blocks: - $ref: '#/components/schemas/ContentBlocks' - required: - - id - - type - - webTitle - - webUrl - - apiUrl - - ContentFields: - type: object - description: Additional fields for content (when requested via show-fields) - properties: - trailText: - type: string - description: Trail text HTML - headline: - type: string - description: Headline HTML - showInRelatedContent: - type: string - description: Whether content can appear in related content (boolean as string) - body: - type: string - description: Full body content HTML - lastModified: - type: string - format: date-time - description: Last modification date - hasStoryPackage: - type: string - description: Whether content has related story package (boolean as string) - score: - type: string - description: Relevance score (float as string) - standfirst: - type: string - description: Standfirst HTML - shortUrl: - type: string - format: uri - description: Short URL - thumbnail: - type: string - format: uri - description: Thumbnail image URL - wordcount: - type: string - description: Word count (integer as string) - commentable: - type: string - description: Whether comments are allowed (boolean as string) - isPremoderated: - type: string - description: Whether comments are premoderated (boolean as string) - allowUgc: - type: string - description: Whether user generated content is allowed (boolean as string) - byline: - type: string - description: Byline HTML - publication: - type: string - description: Publication name - internalPageCode: - type: string - description: Internal page code - productionOffice: - type: string - description: Production office - shouldHideAdverts: - type: string - description: Whether to hide adverts (boolean as string) - liveBloggingNow: - type: string - description: Whether currently live blogging (boolean as string) - commentCloseDate: - type: string - format: date-time - description: Date when comments were closed - starRating: - type: string - description: Star rating (integer as string) - - ContentSearchResponse: - allOf: - - $ref: '#/components/schemas/BaseResponse' - - type: object - properties: - startIndex: - type: integer - description: Starting index of results - example: 1 - pageSize: - type: integer - description: Number of items per page - example: 10 - currentPage: - type: integer - description: Current page number - example: 1 - pages: - type: integer - description: Total number of pages - example: 1 - orderBy: - type: string - description: Sort order used - example: "newest" - results: - type: array - items: - $ref: '#/components/schemas/ContentItem' - required: - - results - - SingleItemResponse: - allOf: - - $ref: '#/components/schemas/BaseResponse' - - type: object - properties: - content: - $ref: '#/components/schemas/ContentItem' - leadContent: - type: array - description: Key pieces of lead content for tags - items: - $ref: '#/components/schemas/ContentItem' - storyPackage: - type: array - description: Related content in same story - items: - $ref: '#/components/schemas/ContentItem' - editorsPicks: - type: array - description: Editor-chosen content - items: - $ref: '#/components/schemas/ContentItem' - mostViewed: - type: array - description: Most viewed content - items: - $ref: '#/components/schemas/ContentItem' - relatedContent: - type: array - description: Related content items - items: - $ref: '#/components/schemas/ContentItem' - - Tag: - type: object - properties: - id: - type: string - description: Tag identifier - example: "katine/football" - type: - type: string - description: Type of tag - enum: [keyword, series, contributor, tone, type, blog] - example: "keyword" - webTitle: - type: string - description: Web title of the tag - example: "Football" - webUrl: - type: string - format: uri - description: Web URL of the tag - example: "http://www.theguardian.com/katine/football" - apiUrl: - type: string - format: uri - description: API URL of the tag - example: "http://beta.content.guardianapis.com/katine/football" - sectionId: - type: string - description: Section ID of the tag - example: "katine" - sectionName: - type: string - description: Section name of the tag - example: "Katine" - references: - type: array - items: - $ref: '#/components/schemas/Reference' - required: - - id - - type - - webTitle - - TagsResponse: - allOf: - - $ref: '#/components/schemas/BaseResponse' - - type: object - properties: - startIndex: - type: integer - description: Starting index of results - example: 1 - pageSize: - type: integer - description: Number of items per page - example: 10 - currentPage: - type: integer - description: Current page number - example: 1 - pages: - type: integer - description: Total number of pages - example: 7 - results: - type: array - items: - $ref: '#/components/schemas/Tag' - required: - - results - - Section: - type: object - properties: - id: - type: string - description: Section identifier - example: "football" - webTitle: - type: string - description: Web title of the section - example: "Football" - webUrl: - type: string - format: uri - description: Web URL of the section - example: "https://www.theguardian.com/football" - apiUrl: - type: string - format: uri - description: API URL of the section - example: "https://content.guardianapis.com/football" - editions: - type: array - items: - $ref: '#/components/schemas/Edition' - required: - - id - - webTitle - - SectionsResponse: - allOf: - - $ref: '#/components/schemas/BaseResponse' - - type: object - properties: - results: - type: array - items: - $ref: '#/components/schemas/Section' - required: - - results - - Edition: - type: object - properties: - id: - type: string - description: Edition identifier - example: "au" - path: - type: string - description: Path of the edition - example: "au" - edition: - type: string - description: Edition name - example: "AU" - webTitle: - type: string - description: Web title of the edition - example: "new guardian australia front page" - webUrl: - type: string - format: uri - description: Web URL of the edition - example: "https://www.theguardian.com/au" - apiUrl: - type: string - format: uri - description: API URL of the edition - example: "https://content.guardianapis.com/au" - code: - type: string - description: Edition code - example: "default" - required: - - id - - edition - - EditionsResponse: - allOf: - - $ref: '#/components/schemas/BaseResponse' - - type: object - properties: - results: - type: array - items: - $ref: '#/components/schemas/Edition' - required: - - results - - Element: - type: object - description: Media element (image, video, audio) - properties: - id: - type: string - description: Element identifier - relation: - type: string - description: Relationship to content - type: - type: string - enum: [image, video, audio] - description: Type of element - assets: - type: array - items: - $ref: '#/components/schemas/Asset' - - Asset: - type: object - description: Media asset within an element - properties: - type: - type: string - description: Asset type - mimeType: - type: string - description: MIME type of the asset - file: - type: string - format: uri - description: URL of the asset file - typeData: - type: object - description: Additional type-specific data - - Reference: - type: object - description: Reference data (ISBN, IMDB, etc.) - properties: - type: - type: string - description: Type of reference - enum: [ - author, bisac-prefix, esa-cricket-match, esa-football-match, - esa-football-team, esa-football-tournament, isbn, imdb, musicbrainz, - musicbrainzgenre, opta-cricket-match, opta-football-match, - opta-football-team, opta-football-tournament, pa-football-competition, - pa-football-match, pa-football-team, r1-film, reuters-index-ric, - reuters-stock-ric, witness-assignment - ] - id: - type: string - description: Reference identifier - - ContentBlocks: - type: object - description: Content blocks for articles and live blogs - properties: - main: - $ref: '#/components/schemas/Block' - body: - type: array - items: - $ref: '#/components/schemas/Block' - - Block: - type: object - description: Individual content block - properties: - id: - type: string - description: Block identifier - bodyHtml: - type: string - description: HTML content of the block - bodyTextSummary: - type: string - description: Text summary of the block - title: - type: string - description: Block title - attributes: - type: object - description: Block attributes - published: - type: boolean - description: Whether the block is published - createdDate: - type: string - format: date-time - description: Block creation date - firstPublishedDate: - type: string - format: date-time - description: First publication date - publishedDate: - type: string - format: date-time - description: Publication date - lastModifiedDate: - type: string - format: date-time - description: Last modification date - contributors: - type: array - items: - type: string - description: Block contributors - elements: - type: array - items: - $ref: '#/components/schemas/Element' - -tags: - - name: Content - description: Search and retrieve Guardian content - - name: Tags - description: Manage content tags and categories - - name: Sections - description: Browse content sections - - name: Editions - description: Access regional editions - - name: Single Item - description: Retrieve individual items by path \ No newline at end of file From 46e808012cd02f9b10320d6a5a7b8f0582390651 Mon Sep 17 00:00:00 2001 From: Andrew Tarr Date: Mon, 11 Aug 2025 19:42:03 -0500 Subject: [PATCH 2/7] Cleaning up the client quite a bit --- .../GuardianClient/GuardianApiClient.cs | 136 ++++++------------ .../GuardianClient/Internal/AssemblyInfo.cs | 16 +++ .../Internal/UrlParameterBuilder.cs | 117 +++++++++++++++ .../Search/GuardianApiContentOrderOptions.cs | 2 +- .../Search/GuardianApiContentSearchOptions.cs | 3 +- 5 files changed, 179 insertions(+), 95 deletions(-) create mode 100644 GuardianClient/GuardianClient/Internal/AssemblyInfo.cs create mode 100644 GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs diff --git a/GuardianClient/GuardianClient/GuardianApiClient.cs b/GuardianClient/GuardianClient/GuardianApiClient.cs index c070f05..0ef4181 100644 --- a/GuardianClient/GuardianClient/GuardianApiClient.cs +++ b/GuardianClient/GuardianClient/GuardianApiClient.cs @@ -1,5 +1,5 @@ -using System.Reflection; using System.Text.Json; +using GuardianClient.Internal; using GuardianClient.Models; using GuardianClient.Options.Search; @@ -8,11 +8,8 @@ namespace GuardianClient; public class GuardianApiClient : IDisposable { private readonly HttpClient _httpClient; - private readonly string _apiKey; - private readonly bool _ownsHttpClient; - private bool _disposed; private const string BaseUrl = "https://content.guardianapis.com"; @@ -55,34 +52,31 @@ public GuardianApiClient(string apiKey) ConfigureHttpClient(); } - private void ConfigureHttpClient() - { - var packageVersion = GetPackageVersion(); - - _httpClient.BaseAddress = new Uri(BaseUrl); - _httpClient.DefaultRequestHeaders.Add("User-Agent", $"GuardianClient.NET/{packageVersion}"); - } - - private string GetPackageVersion() - { - var packageVersion = Assembly - .GetExecutingAssembly() - .GetCustomAttribute()! - .InformationalVersion; - - return packageVersion; - } - - // TODO comment out of date /// - /// Search for Guardian content + /// Search for Guardian content using comprehensive search options. /// - /// Search query (supports AND, OR, NOT operators) - /// Number of results per page (1-50) - /// Page number for pagination - /// API options for including additional response data - /// Cancellation token - /// Content search results + /// + /// Search options including query terms, filters, pagination, ordering, date ranges, and additional information to include. + /// If null, returns all content with default settings (newest first, page 1, 10 items per page). + /// + /// Cancellation token to cancel the HTTP request + /// + /// A containing the search results, pagination info, and metadata. + /// Returns null if the API response cannot be deserialized. + /// + /// Thrown when the API request fails or returns a non-success status code + /// Thrown when the request is cancelled via the cancellation token + /// + /// This method provides access to the full Guardian Content API search functionality, including: + /// + /// Free text search with boolean operators (AND, OR, NOT) + /// Filtering by section, tags, references, production office, language, star rating, and more + /// Date range filtering with different date types (published, first-publication, newspaper-edition, last-modified) + /// Pagination and result ordering options + /// Additional fields, tags, elements, references, and blocks in responses + /// + /// For simple searches, you can create basic options: new GuardianApiContentSearchOptions { Query = "your search terms" } + /// public async Task SearchAsync( GuardianApiContentSearchOptions? options = null, CancellationToken cancellationToken = default @@ -92,45 +86,12 @@ private string GetPackageVersion() var parameters = new List { $"api-key={Uri.EscapeDataString(_apiKey)}" }; - // if (!string.IsNullOrWhiteSpace(query)) - // { - // parameters.Add($"q={Uri.EscapeDataString(query)}"); - // } - // - // if (pageSize.HasValue) - // { - // parameters.Add($"page-size={pageSize.Value}"); - // } - // - // if (page.HasValue) - // { - // parameters.Add($"page={page.Value}"); - // } - // - // if (options?.ShowFields?.Length > 0) - // { - // parameters.Add($"show-fields={string.Join(",", options.ShowFields)}"); - // } - // - // if (options?.ShowTags?.Length > 0) - // { - // parameters.Add($"show-tags={string.Join(",", options.ShowTags)}"); - // } - // - // if (options?.ShowElements?.Length > 0) - // { - // parameters.Add($"show-elements={string.Join(",", options.ShowElements)}"); - // } - // - // if (options?.ShowReferences?.Length > 0) - // { - // parameters.Add($"show-references={string.Join(",", options.ShowReferences)}"); - // } - // - // if (options?.ShowBlocks?.Length > 0) - // { - // parameters.Add($"show-blocks={string.Join(",", options.ShowBlocks)}"); - // } + UrlParameterBuilder.AddQueryParameters(options, parameters); + UrlParameterBuilder.AddFilterParameters(options.FilterOptions, parameters); + UrlParameterBuilder.AddDateParameters(options.DateOptions, parameters); + UrlParameterBuilder.AddPageParameters(options.PageOptions, parameters); + UrlParameterBuilder.AddOrderParameters(options.OrderOptions, parameters); + UrlParameterBuilder.AddAdditionalInformationParameters(options.AdditionalInformationOptions, parameters); var url = $"/search?{string.Join("&", parameters)}"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -159,30 +120,11 @@ private string GetPackageVersion() var parameters = new List { $"api-key={Uri.EscapeDataString(_apiKey)}" }; - if (options?.ShowFields?.Length > 0) - { - parameters.Add($"show-fields={string.Join(",", options.ShowFields)}"); - } - - if (options?.ShowTags?.Length > 0) - { - parameters.Add($"show-tags={string.Join(",", options.ShowTags)}"); - } - - if (options?.ShowElements?.Length > 0) - { - parameters.Add($"show-elements={string.Join(",", options.ShowElements)}"); - } - - if (options?.ShowReferences?.Length > 0) - { - parameters.Add($"show-references={string.Join(",", options.ShowReferences)}"); - } - - if (options?.ShowBlocks?.Length > 0) - { - parameters.Add($"show-blocks={string.Join(",", options.ShowBlocks)}"); - } + UrlParameterBuilder.AddParameterIfAny(parameters, "show-fields", options?.ShowFields); + UrlParameterBuilder.AddParameterIfAny(parameters, "show-tags", options?.ShowTags); + UrlParameterBuilder.AddParameterIfAny(parameters, "show-elements", options?.ShowElements); + UrlParameterBuilder.AddParameterIfAny(parameters, "show-references", options?.ShowReferences); + UrlParameterBuilder.AddParameterIfAny(parameters, "show-blocks", options?.ShowBlocks); var url = $"/{itemId}?{string.Join("&", parameters)}"; var response = await _httpClient.GetAsync(url, cancellationToken); @@ -201,6 +143,14 @@ public void Dispose() GC.SuppressFinalize(this); } + private void ConfigureHttpClient() + { + var packageVersion = AssemblyInfo.GetPackageVersion(); + + _httpClient.BaseAddress = new Uri(BaseUrl); + _httpClient.DefaultRequestHeaders.Add("User-Agent", $"GuardianClient.NET/{packageVersion}"); + } + /// /// Disposes the if: /// 1. This instance owns it, and diff --git a/GuardianClient/GuardianClient/Internal/AssemblyInfo.cs b/GuardianClient/GuardianClient/Internal/AssemblyInfo.cs new file mode 100644 index 0000000..bbb6fe7 --- /dev/null +++ b/GuardianClient/GuardianClient/Internal/AssemblyInfo.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace GuardianClient.Internal; + +internal static class AssemblyInfo +{ + internal static string GetPackageVersion() + { + var packageVersion = Assembly + .GetExecutingAssembly() + .GetCustomAttribute()! + .InformationalVersion; + + return packageVersion; + } +} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs b/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs new file mode 100644 index 0000000..f7ad8ac --- /dev/null +++ b/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs @@ -0,0 +1,117 @@ +using GuardianClient.Options.Search; + +namespace GuardianClient.Internal; + +internal static class UrlParameterBuilder +{ + internal static void AddParameterIfNotEmpty(List parameters, string parameterName, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + parameters.Add($"{parameterName}={Uri.EscapeDataString(value)}"); + } + } + + internal static void AddParameterIfHasValue(List parameters, string parameterName, T? value) where T : struct + { + if (value.HasValue) + { + parameters.Add($"{parameterName}={value.Value}"); + } + } + + internal static void AddParameterIfAny(List parameters, string parameterName, string[]? values) + { + if (values is { Length: > 0 }) + { + parameters.Add($"{parameterName}={string.Join(",", values.Select(Uri.EscapeDataString))}"); + } + } + + internal static void AddQueryParameters(GuardianApiContentSearchOptions options, List parameters) + { + AddParameterIfNotEmpty(parameters, "q", options.Query); + AddParameterIfAny(parameters, "query-fields", options.QueryFields); + } + + internal static void AddFilterParameters(GuardianApiContentFilterOptions filterOptions, List parameters) + { + AddParameterIfNotEmpty(parameters, "section", filterOptions.Section); + AddParameterIfNotEmpty(parameters, "reference", filterOptions.Reference); + AddParameterIfNotEmpty(parameters, "reference-type", filterOptions.ReferenceType); + AddParameterIfNotEmpty(parameters, "tag", filterOptions.Tag); + AddParameterIfNotEmpty(parameters, "rights", filterOptions.Rights); + AddParameterIfNotEmpty(parameters, "ids", filterOptions.Ids); + AddParameterIfNotEmpty(parameters, "production-office", filterOptions.ProductionOffice); + AddParameterIfNotEmpty(parameters, "lang", filterOptions.Language); + + AddParameterIfHasValue(parameters, "star-rating", filterOptions.StarRating); + } + + internal static void AddDateParameters(GuardianApiContentDateOptions dateOptions, List parameters) + { + if (dateOptions.FromDate != default) + { + parameters.Add($"from-date={dateOptions.FromDate:yyyy-MM-dd}"); + } + + if (dateOptions.ToDate != default) + { + parameters.Add($"to-date={dateOptions.ToDate:yyyy-MM-dd}"); + } + + AddParameterIfNotEmpty(parameters, "use-date", dateOptions.UseDate); + } + + internal static void AddPageParameters(GuardianApiContentPageOptions pageOptions, List parameters) + { + if (pageOptions.Page > 0) + { + parameters.Add($"page={pageOptions.Page}"); + } + + if (pageOptions.PageSize > 0) + { + parameters.Add($"page-size={pageOptions.PageSize}"); + } + } + + internal static void AddOrderParameters(GuardianApiContentOrderOptions orderOptions, List parameters) + { + if (orderOptions.OrderBy.HasValue) + { + var orderByValue = orderOptions.OrderBy.Value switch + { + GuardianApiOrderBy.Newest => "newest", + GuardianApiOrderBy.Oldest => "oldest", + GuardianApiOrderBy.Relevance => "relevance", + _ => "newest" + }; + parameters.Add($"order-by={orderByValue}"); + } + + if (orderOptions.OrderDate.HasValue) + { + var orderDateValue = orderOptions.OrderDate.Value switch + { + GuardianApiOrderDate.Published => "published", + GuardianApiOrderDate.NewspaperEdition => "newspaper-edition", + GuardianApiOrderDate.LastModified => "last-modified", + _ => "published" + }; + parameters.Add($"order-date={orderDateValue}"); + } + } + + internal static void AddAdditionalInformationParameters( + GuardianApiContentAdditionalInformationOptions additionalOptions, + List parameters + ) + { + AddParameterIfAny(parameters, "show-fields", additionalOptions.ShowFields); + AddParameterIfAny(parameters, "show-tags", additionalOptions.ShowTags); + AddParameterIfAny(parameters, "show-elements", additionalOptions.ShowElements); + AddParameterIfAny(parameters, "show-references", additionalOptions.ShowReferences); + AddParameterIfAny(parameters, "show-blocks", additionalOptions.ShowBlocks); + } +} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs index 09e960c..584fc64 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs @@ -13,5 +13,5 @@ public class GuardianApiContentOrderOptions /// /// Changes which type of date is used to order the results. Defaults to Published. /// - public GuardianApiOrderDate OrderDate { get; set; } + public GuardianApiOrderDate? OrderDate { get; set; } } diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs index 49c7768..4892bbc 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs @@ -3,7 +3,8 @@ namespace GuardianClient.Options.Search; /// /// Options for searching content using the Guardian API. /// -public class GuardianApiContentSearchOptions +public class + GuardianApiContentSearchOptions { /// /// Request content containing this free text. Supports AND, OR and NOT operators, and exact phrase queries using double quotes. From dea7d46ead8d1b99adeb39e62306d073567c449e Mon Sep 17 00:00:00 2001 From: Andrew Tarr Date: Mon, 11 Aug 2025 19:49:03 -0500 Subject: [PATCH 3/7] Throw the client behind an interface to help people out with DI --- .../GuardianClient/GuardianApiClient.cs | 34 +----------- .../GuardianClient/IGuardianApiClient.cs | 52 +++++++++++++++++++ .../Search/GuardianApiContentFilterOptions.cs | 3 ++ .../Search/GuardianApiContentOrderOptions.cs | 3 ++ .../Search/GuardianApiContentPageOptions.cs | 3 ++ .../Search/GuardianApiContentSearchOptions.cs | 6 ++- 6 files changed, 66 insertions(+), 35 deletions(-) create mode 100644 GuardianClient/GuardianClient/IGuardianApiClient.cs diff --git a/GuardianClient/GuardianClient/GuardianApiClient.cs b/GuardianClient/GuardianClient/GuardianApiClient.cs index 0ef4181..b845290 100644 --- a/GuardianClient/GuardianClient/GuardianApiClient.cs +++ b/GuardianClient/GuardianClient/GuardianApiClient.cs @@ -5,7 +5,7 @@ namespace GuardianClient; -public class GuardianApiClient : IDisposable +public class GuardianApiClient : IGuardianApiClient, IDisposable { private readonly HttpClient _httpClient; private readonly string _apiKey; @@ -52,31 +52,6 @@ public GuardianApiClient(string apiKey) ConfigureHttpClient(); } - /// - /// Search for Guardian content using comprehensive search options. - /// - /// - /// Search options including query terms, filters, pagination, ordering, date ranges, and additional information to include. - /// If null, returns all content with default settings (newest first, page 1, 10 items per page). - /// - /// Cancellation token to cancel the HTTP request - /// - /// A containing the search results, pagination info, and metadata. - /// Returns null if the API response cannot be deserialized. - /// - /// Thrown when the API request fails or returns a non-success status code - /// Thrown when the request is cancelled via the cancellation token - /// - /// This method provides access to the full Guardian Content API search functionality, including: - /// - /// Free text search with boolean operators (AND, OR, NOT) - /// Filtering by section, tags, references, production office, language, star rating, and more - /// Date range filtering with different date types (published, first-publication, newspaper-edition, last-modified) - /// Pagination and result ordering options - /// Additional fields, tags, elements, references, and blocks in responses - /// - /// For simple searches, you can create basic options: new GuardianApiContentSearchOptions { Query = "your search terms" } - /// public async Task SearchAsync( GuardianApiContentSearchOptions? options = null, CancellationToken cancellationToken = default @@ -104,13 +79,6 @@ public GuardianApiClient(string apiKey) return wrapper?.Response; } - /// - /// Get a single content item by its ID/path - /// - /// The content item ID (path from Guardian API) - /// API options for including additional response data - /// Cancellation token - /// Single item response with content details public async Task GetItemAsync( string itemId, GuardianApiContentAdditionalInformationOptions? options = null, diff --git a/GuardianClient/GuardianClient/IGuardianApiClient.cs b/GuardianClient/GuardianClient/IGuardianApiClient.cs new file mode 100644 index 0000000..db1787f --- /dev/null +++ b/GuardianClient/GuardianClient/IGuardianApiClient.cs @@ -0,0 +1,52 @@ +using GuardianClient.Models; +using GuardianClient.Options.Search; + +namespace GuardianClient; + +/// +/// Interface for the Guardian API client, providing access to Guardian content search and retrieval. +/// +public interface IGuardianApiClient +{ + /// + /// Search for Guardian content using comprehensive search options. + /// + /// + /// Search options including query terms, filters, pagination, ordering, date ranges, and additional information to include. + /// If null, returns all content with default settings (newest first, page 1, 10 items per page). + /// + /// Cancellation token to cancel the HTTP request + /// + /// A containing the search results, pagination info, and metadata. + /// Returns null if the API response cannot be deserialized. + /// + /// Thrown when the API request fails or returns a non-success status code + /// Thrown when the request is cancelled via the cancellation token + /// + /// This method provides access to the full Guardian Content API search functionality, including: + /// + /// Free text search with boolean operators (AND, OR, NOT) + /// Filtering by section, tags, references, production office, language, star rating, and more + /// Date range filtering with different date types (published, first-publication, newspaper-edition, last-modified) + /// Pagination and result ordering options + /// Additional fields, tags, elements, references, and blocks in responses + /// + /// For simple searches, you can create basic options: new GuardianApiContentSearchOptions { Query = "your search terms" } + /// + Task SearchAsync( + GuardianApiContentSearchOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Get a single content item by its ID/path + /// + /// The content item ID (path from Guardian API) + /// API options for including additional response data + /// Cancellation token + /// Single item response with content details + Task GetItemAsync( + string itemId, + GuardianApiContentAdditionalInformationOptions? options = null, + CancellationToken cancellationToken = default); +} diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs index 220b3e9..e838e60 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentFilterOptions.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GuardianClient.Options.Search; /// /// Options for filtering content search results by various criteria. /// +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] public class GuardianApiContentFilterOptions { /// diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs index 584fc64..2fd0143 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GuardianClient.Options.Search; /// /// Options for controlling the ordering of search results. /// +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] public class GuardianApiContentOrderOptions { /// diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs index df86b78..04ba1ee 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentPageOptions.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GuardianClient.Options.Search; /// /// Options for controlling pagination of search results. /// +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] public class GuardianApiContentPageOptions { /// diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs index 4892bbc..01fbf4d 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentSearchOptions.cs @@ -1,10 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + namespace GuardianClient.Options.Search; /// /// Options for searching content using the Guardian API. /// -public class - GuardianApiContentSearchOptions +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] +public class GuardianApiContentSearchOptions { /// /// Request content containing this free text. Supports AND, OR and NOT operators, and exact phrase queries using double quotes. From 4129d79eddebb767ffb2f5e338eab25085e01e6b Mon Sep 17 00:00:00 2001 From: Andrew Tarr Date: Mon, 11 Aug 2025 20:06:32 -0500 Subject: [PATCH 4/7] Adding more enums and verifying tests pass --- .../GuardianApiClientTests.cs | 20 +- .../GuardianClient.Tests/TestBase.cs | 2 +- .../GuardianClient/GuardianApiClient.cs | 36 +++- .../GuardianClient/IGuardianApiClient.cs | 3 +- .../Internal/UrlParameterBuilder.cs | 16 +- .../Search/AdditionalInformationEnums.cs | 189 ++++++++++++++++++ ...nApiContentAdditionalInformationOptions.cs | 43 ++-- 7 files changed, 282 insertions(+), 27 deletions(-) create mode 100644 GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs diff --git a/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs b/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs index fadc4d7..3a1a820 100644 --- a/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs +++ b/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs @@ -10,7 +10,11 @@ public class GuardianApiClientTests : TestBase [TestMethod] public async Task SearchAsyncSmokeTest() { - var result = await ApiClient.SearchAsync("climate change", pageSize: 5); + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "climate change", + PageOptions = new GuardianApiContentPageOptions { PageSize = 5 } + }); result.ShouldNotBeNull("Search result should not be null"); result.Status.ShouldBe("ok", "API response status should be 'ok'"); @@ -31,7 +35,10 @@ public async Task SearchAsyncSmokeTest() [TestMethod] public async Task SearchAsyncWithNoResults() { - var result = await ApiClient.SearchAsync("xyzabc123nonexistentquery456"); + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "xyzabc123nonexistentquery456" + }); result.ShouldNotBeNull("Search result should not be null even with no matches"); result.Status.ShouldBe("ok", "API response status should be 'ok'"); @@ -41,13 +48,18 @@ public async Task SearchAsyncWithNoResults() [TestMethod] public async Task GetItemAsyncSmokeTest() { - var searchResult = await ApiClient.SearchAsync("technology", pageSize: 1); + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "technology", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } + }); searchResult.ShouldNotBeNull("Search should return results"); searchResult.Results.Count.ShouldBe(1, "Should return exactly one result"); var contentItem = searchResult.Results.First(); var itemId = contentItem.Id; - var singleItemResult = await ApiClient.GetItemAsync(itemId, new GuardianApiContentAdditionalInformationOptions { ShowFields = ["body"] }); + var singleItemResult = await ApiClient.GetItemAsync(itemId, + new GuardianApiContentAdditionalInformationOptions { ShowFields = [ShowFieldsOption.Body] }); singleItemResult.ShouldNotBeNull("GetItem result should not be null"); singleItemResult.Status.ShouldBe("ok", "API response status should be 'ok'"); diff --git a/GuardianClient/GuardianClient.Tests/TestBase.cs b/GuardianClient/GuardianClient.Tests/TestBase.cs index 5960da5..45d2a50 100644 --- a/GuardianClient/GuardianClient.Tests/TestBase.cs +++ b/GuardianClient/GuardianClient.Tests/TestBase.cs @@ -6,7 +6,7 @@ namespace GuardianClient.Tests; public abstract class TestBase { - protected static GuardianApiClient ApiClient { get; } + protected static IGuardianApiClient ApiClient { get; } static TestBase() { diff --git a/GuardianClient/GuardianClient/GuardianApiClient.cs b/GuardianClient/GuardianClient/GuardianApiClient.cs index b845290..92272ff 100644 --- a/GuardianClient/GuardianClient/GuardianApiClient.cs +++ b/GuardianClient/GuardianClient/GuardianApiClient.cs @@ -88,11 +88,37 @@ public GuardianApiClient(string apiKey) var parameters = new List { $"api-key={Uri.EscapeDataString(_apiKey)}" }; - UrlParameterBuilder.AddParameterIfAny(parameters, "show-fields", options?.ShowFields); - UrlParameterBuilder.AddParameterIfAny(parameters, "show-tags", options?.ShowTags); - UrlParameterBuilder.AddParameterIfAny(parameters, "show-elements", options?.ShowElements); - UrlParameterBuilder.AddParameterIfAny(parameters, "show-references", options?.ShowReferences); - UrlParameterBuilder.AddParameterIfAny(parameters, "show-blocks", options?.ShowBlocks); + UrlParameterBuilder.AddParameterIfAny( + parameters, + "show-fields", + options?.ShowFields, + option => option.ToApiString() + ); + + UrlParameterBuilder.AddParameterIfAny( + parameters, + "show-tags", + options?.ShowTags, + option => option.ToApiString() + ); + UrlParameterBuilder.AddParameterIfAny( + parameters, + "show-elements", + options?.ShowElements, + option => option.ToApiString() + ); + UrlParameterBuilder.AddParameterIfAny( + parameters, + "show-references", + options?.ShowReferences, + option => option.ToApiString() + ); + + UrlParameterBuilder.AddParameterIfAny( + parameters, + "show-blocks", + options?.ShowBlocks + ); var url = $"/{itemId}?{string.Join("&", parameters)}"; var response = await _httpClient.GetAsync(url, cancellationToken); diff --git a/GuardianClient/GuardianClient/IGuardianApiClient.cs b/GuardianClient/GuardianClient/IGuardianApiClient.cs index db1787f..d3a3f6b 100644 --- a/GuardianClient/GuardianClient/IGuardianApiClient.cs +++ b/GuardianClient/GuardianClient/IGuardianApiClient.cs @@ -48,5 +48,6 @@ public interface IGuardianApiClient Task GetItemAsync( string itemId, GuardianApiContentAdditionalInformationOptions? options = null, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default + ); } diff --git a/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs b/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs index f7ad8ac..fef571f 100644 --- a/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs +++ b/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs @@ -28,6 +28,14 @@ internal static void AddParameterIfAny(List parameters, string parameter } } + internal static void AddParameterIfAny(List parameters, string parameterName, T[]? values, Func converter) + { + if (values is { Length: > 0 }) + { + parameters.Add($"{parameterName}={string.Join(",", values.Select(v => Uri.EscapeDataString(converter(v))))}"); + } + } + internal static void AddQueryParameters(GuardianApiContentSearchOptions options, List parameters) { AddParameterIfNotEmpty(parameters, "q", options.Query); @@ -108,10 +116,10 @@ internal static void AddAdditionalInformationParameters( List parameters ) { - AddParameterIfAny(parameters, "show-fields", additionalOptions.ShowFields); - AddParameterIfAny(parameters, "show-tags", additionalOptions.ShowTags); - AddParameterIfAny(parameters, "show-elements", additionalOptions.ShowElements); - AddParameterIfAny(parameters, "show-references", additionalOptions.ShowReferences); + AddParameterIfAny(parameters, "show-fields", additionalOptions.ShowFields, f => f.ToApiString()); + AddParameterIfAny(parameters, "show-tags", additionalOptions.ShowTags, t => t.ToApiString()); + AddParameterIfAny(parameters, "show-elements", additionalOptions.ShowElements, e => e.ToApiString()); + AddParameterIfAny(parameters, "show-references", additionalOptions.ShowReferences, r => r.ToApiString()); AddParameterIfAny(parameters, "show-blocks", additionalOptions.ShowBlocks); } } \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs b/GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs new file mode 100644 index 0000000..b3dc492 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs @@ -0,0 +1,189 @@ +namespace GuardianClient.Options.Search; + +/// +/// Fields that can be included with content responses. +/// +public enum ShowFieldsOption +{ + TrailText, + Headline, + ShowInRelatedContent, + Body, + LastModified, + HasStoryPackage, + Score, + Standfirst, + ShortUrl, + Thumbnail, + Wordcount, + Commentable, + IsPremoderated, + AllowUgc, + Byline, + Publication, + InternalPageCode, + ProductionOffice, + ShouldHideAdverts, + LiveBloggingNow, + CommentCloseDate, + StarRating, + All +} + +/// +/// Tag types that can be included with content responses. +/// +public enum ShowTagsOption +{ + Blog, + Contributor, + Keyword, + NewspaperBook, + NewspaperBookSection, + Publication, + Series, + Tone, + Type, + All +} + +/// +/// Media element types that can be included with content responses. +/// +public enum ShowElementsOption +{ + Audio, + Image, + Video, + All +} + +/// +/// Reference types that can be included with content responses. +/// +public enum ShowReferencesOption +{ + Author, + BisacPrefix, + EsaCricketMatch, + EsaFootballMatch, + EsaFootballTeam, + EsaFootballTournament, + Isbn, + Imdb, + Musicbrainz, + MusicbrainzGenre, + OptaCricketMatch, + OptaFootballMatch, + OptaFootballTeam, + OptaFootballTournament, + PaFootballCompetition, + PaFootballMatch, + PaFootballTeam, + R1Film, + ReutersIndexRic, + ReutersStockRic, + WitnessAssignment +} + +/// +/// Rights types that can be included with content responses. +/// +public enum ShowRightsOption +{ + Syndicatable, + SubscriptionDatabases, + All +} + +/// +/// Extension methods for converting additional information enums to their API string values. +/// +internal static class AdditionalInformationEnumExtensions +{ + internal static string ToApiString(this ShowFieldsOption option) => option switch + { + ShowFieldsOption.TrailText => "trailText", + ShowFieldsOption.Headline => "headline", + ShowFieldsOption.ShowInRelatedContent => "showInRelatedContent", + ShowFieldsOption.Body => "body", + ShowFieldsOption.LastModified => "lastModified", + ShowFieldsOption.HasStoryPackage => "hasStoryPackage", + ShowFieldsOption.Score => "score", + ShowFieldsOption.Standfirst => "standfirst", + ShowFieldsOption.ShortUrl => "shortUrl", + ShowFieldsOption.Thumbnail => "thumbnail", + ShowFieldsOption.Wordcount => "wordcount", + ShowFieldsOption.Commentable => "commentable", + ShowFieldsOption.IsPremoderated => "isPremoderated", + ShowFieldsOption.AllowUgc => "allowUgc", + ShowFieldsOption.Byline => "byline", + ShowFieldsOption.Publication => "publication", + ShowFieldsOption.InternalPageCode => "internalPageCode", + ShowFieldsOption.ProductionOffice => "productionOffice", + ShowFieldsOption.ShouldHideAdverts => "shouldHideAdverts", + ShowFieldsOption.LiveBloggingNow => "liveBloggingNow", + ShowFieldsOption.CommentCloseDate => "commentCloseDate", + ShowFieldsOption.StarRating => "starRating", + ShowFieldsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this ShowTagsOption option) => option switch + { + ShowTagsOption.Blog => "blog", + ShowTagsOption.Contributor => "contributor", + ShowTagsOption.Keyword => "keyword", + ShowTagsOption.NewspaperBook => "newspaper-book", + ShowTagsOption.NewspaperBookSection => "newspaper-book-section", + ShowTagsOption.Publication => "publication", + ShowTagsOption.Series => "series", + ShowTagsOption.Tone => "tone", + ShowTagsOption.Type => "type", + ShowTagsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this ShowElementsOption option) => option switch + { + ShowElementsOption.Audio => "audio", + ShowElementsOption.Image => "image", + ShowElementsOption.Video => "video", + ShowElementsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this ShowReferencesOption option) => option switch + { + ShowReferencesOption.Author => "author", + ShowReferencesOption.BisacPrefix => "bisac-prefix", + ShowReferencesOption.EsaCricketMatch => "esa-cricket-match", + ShowReferencesOption.EsaFootballMatch => "esa-football-match", + ShowReferencesOption.EsaFootballTeam => "esa-football-team", + ShowReferencesOption.EsaFootballTournament => "esa-football-tournament", + ShowReferencesOption.Isbn => "isbn", + ShowReferencesOption.Imdb => "imdb", + ShowReferencesOption.Musicbrainz => "musicbrainz", + ShowReferencesOption.MusicbrainzGenre => "musicbrainzgenre", + ShowReferencesOption.OptaCricketMatch => "opta-cricket-match", + ShowReferencesOption.OptaFootballMatch => "opta-football-match", + ShowReferencesOption.OptaFootballTeam => "opta-football-team", + ShowReferencesOption.OptaFootballTournament => "opta-football-tournament", + ShowReferencesOption.PaFootballCompetition => "pa-football-competition", + ShowReferencesOption.PaFootballMatch => "pa-football-match", + ShowReferencesOption.PaFootballTeam => "pa-football-team", + ShowReferencesOption.R1Film => "r1-film", + ShowReferencesOption.ReutersIndexRic => "reuters-index-ric", + ShowReferencesOption.ReutersStockRic => "reuters-stock-ric", + ShowReferencesOption.WitnessAssignment => "witness-assignment", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this ShowRightsOption option) => option switch + { + ShowRightsOption.Syndicatable => "syndicatable", + ShowRightsOption.SubscriptionDatabases => "subscription-databases", + ShowRightsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; +} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs index 8f37c27..22373f8 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs @@ -1,32 +1,51 @@ +using System.Diagnostics.CodeAnalysis; + namespace GuardianClient.Options.Search; +/// +/// Options for requesting additional information to be included with search results. +/// +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] public class GuardianApiContentAdditionalInformationOptions { /// - /// + /// Add fields associated with the content such as headline, body, thumbnail, etc. /// - public string[]? ShowFields { get; set; } + public ShowFieldsOption[]? ShowFields { get; set; } /// - /// + /// Add associated metadata tags such as contributor, keyword, tone, etc. /// - public string[]? ShowTags { get; set; } + public ShowTagsOption[]? ShowTags { get; set; } /// - /// + /// Add associated media elements such as images, audio, and video. /// - public string[]? ShowElements { get; set; } + public ShowElementsOption[]? ShowElements { get; set; } /// - /// + /// Add associated reference data such as ISBNs, IMDB IDs, author references, etc. /// - public string[]? ShowReferences { get; set; } + public ShowReferencesOption[]? ShowReferences { get; set; } + /// - /// + /// Add associated blocks (single block for content, one or more for liveblogs). + /// Supported values: + /// + /// "main" - Main content block + /// "body" - Body content blocks + /// "all" - All blocks + /// "body:latest" - Latest body blocks (default limit: 20) + /// "body:latest:10" - Latest 10 body blocks + /// "body:oldest" - Oldest body blocks + /// "body:oldest:10" - Oldest 10 body blocks + /// "body:<block-id>" - Specific block by ID + /// "body:around:<block-id>" - Block and 20 blocks around it + /// "body:around:<block-id>:10" - Block and 10 blocks around it + /// "body:key-events" - Key event blocks + /// "body:published-since:<timestamp>" - Blocks since timestamp (e.g., "body:published-since:1556529318000") + /// /// public string[]? ShowBlocks { get; set; } } - -// TODO additional information table below shows all the options that are available in the Show* methods, so -// we could strongly type those. From bdbf87dfbe2aecf624b24d358e4545ae1e6085a8 Mon Sep 17 00:00:00 2001 From: Andrew Tarr Date: Mon, 11 Aug 2025 20:14:32 -0500 Subject: [PATCH 5/7] Test separation and cleanup --- .../GuardianClient.Tests/GetItemAsyncTests.cs | 245 ++++++++++++++++++ .../GuardianApiClientIntegrationTests.cs | 136 ++++++++++ .../GuardianApiClientTests.cs | 82 ------ .../GuardianClient.Tests/SearchAsyncTests.cs | 144 ++++++++++ .../GuardianClient/GuardianClient.csproj | 4 +- .../Internal/UrlParameterBuilder.cs | 12 +- ...rderBy.cs => GuardianApiContentOrderBy.cs} | 2 +- ...Date.cs => GuardianApiContentOrderDate.cs} | 2 +- .../Search/GuardianApiContentOrderOptions.cs | 4 +- 9 files changed, 537 insertions(+), 94 deletions(-) create mode 100644 GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs create mode 100644 GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs delete mode 100644 GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs create mode 100644 GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs rename GuardianClient/GuardianClient/Options/Search/{GuardianApiOrderBy.cs => GuardianApiContentOrderBy.cs} (92%) rename GuardianClient/GuardianClient/Options/Search/{GuardianApiOrderDate.cs => GuardianApiContentOrderDate.cs} (91%) diff --git a/GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs b/GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs new file mode 100644 index 0000000..a0dcbe5 --- /dev/null +++ b/GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs @@ -0,0 +1,245 @@ +using GuardianClient.Options.Search; +using Shouldly; + +namespace GuardianClient.Tests; + +[TestClass] +public class GetItemAsyncTests : TestBase +{ + [TestMethod] + public async Task GetItemAsync_WithValidId_ReturnsItem() + { + // First get a valid item ID from search + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "technology", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } + }); + searchResult.ShouldNotBeNull("Search should return results"); + searchResult.Results.Count.ShouldBe(1, "Should return exactly one result"); + + var contentItem = searchResult.Results.First(); + var itemId = contentItem.Id; + + // Now get the specific item + var singleItemResult = await ApiClient.GetItemAsync(itemId, + new GuardianApiContentAdditionalInformationOptions { ShowFields = [ShowFieldsOption.Body] }); + + singleItemResult.ShouldNotBeNull("GetItem result should not be null"); + singleItemResult.Status.ShouldBe("ok", "API response status should be 'ok'"); + singleItemResult.Content.ShouldNotBeNull("Content should not be null"); + singleItemResult.Content.Id.ShouldBe(itemId, "Returned content ID should match requested ID"); + singleItemResult.Content.WebTitle.ShouldNotBeNullOrEmpty("Content should have a title"); + singleItemResult.Content.Fields.ShouldNotBeNull("Fields should be populated when ShowFields is specified"); + + Console.WriteLine($"Retrieved item: {singleItemResult.Content.WebTitle}"); + Console.WriteLine($"Item ID: {singleItemResult.Content.Id}"); + Console.WriteLine($"Published: {singleItemResult.Content.WebPublicationDate}"); + + if (!string.IsNullOrEmpty(singleItemResult.Content.Fields.Body)) + { + Console.WriteLine($"Body length: {singleItemResult.Content.Fields.Body.Length} characters"); + Console.WriteLine( + $"Body preview: {singleItemResult.Content.Fields.Body[..Math.Min(200, singleItemResult.Content.Fields.Body.Length)]}..."); + } + } + + [TestMethod] + public async Task GetItemAsync_WithInvalidId_ThrowsException() + { + var invalidId = "invalid/article/id/that/does/not/exist"; + + var exception = await Should.ThrowAsync(async () => + { + await ApiClient.GetItemAsync(invalidId); + }); + + Console.WriteLine($"Expected exception for invalid ID: {exception.Message}"); + } + + [TestMethod] + public async Task GetItemAsync_WithNullId_ThrowsArgumentException() + { + var exception = await Should.ThrowAsync(async () => + { + await ApiClient.GetItemAsync(null!); + }); + + Console.WriteLine($"Expected exception for null ID: {exception.Message}"); + } + + [TestMethod] + public async Task GetItemAsync_WithEmptyId_ThrowsArgumentException() + { + var exception = await Should.ThrowAsync(async () => + { + await ApiClient.GetItemAsync(""); + }); + + Console.WriteLine($"Expected exception for empty ID: {exception.Message}"); + } + + [TestMethod] + public async Task GetItemAsync_WithShowFields_ReturnsEnhancedContent() + { + // Get a valid item ID + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "politics", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } + }); + + var itemId = searchResult.Results.First().Id; + + // Request with multiple fields + var result = await ApiClient.GetItemAsync(itemId, + new GuardianApiContentAdditionalInformationOptions + { + ShowFields = + [ + ShowFieldsOption.Headline, + ShowFieldsOption.Body, + ShowFieldsOption.Byline, + ShowFieldsOption.Thumbnail + ] + }); + + result.ShouldNotBeNull(); + result.Content.Fields.ShouldNotBeNull("Fields should be populated"); + result.Content.Fields.Headline.ShouldNotBeNullOrEmpty("Headline should be populated"); + + Console.WriteLine($"Enhanced content for: {result.Content.WebTitle}"); + Console.WriteLine($"Headline: {result.Content.Fields.Headline}"); + Console.WriteLine($"Has body: {!string.IsNullOrEmpty(result.Content.Fields.Body)}"); + Console.WriteLine($"Has byline: {!string.IsNullOrEmpty(result.Content.Fields.Byline)}"); + } + + [TestMethod] + public async Task GetItemAsync_WithShowTags_ReturnsContentWithTags() + { + // Get a valid item ID + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "sport", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } + }); + + var itemId = searchResult.Results.First().Id; + + // Request with tags + var result = await ApiClient.GetItemAsync(itemId, + new GuardianApiContentAdditionalInformationOptions + { + ShowTags = [ShowTagsOption.Keyword, ShowTagsOption.Tone, ShowTagsOption.Type] + }); + + result.ShouldNotBeNull(); + result.Content.Tags.ShouldNotBeNull("Tags should be populated"); + result.Content.Tags.Count.ShouldBeGreaterThan(0, "Should have at least one tag"); + + Console.WriteLine($"Content '{result.Content.WebTitle}' has {result.Content.Tags.Count} tags:"); + foreach (var tag in result.Content.Tags.Take(5)) // Show first 5 tags + { + Console.WriteLine($" - {tag.WebTitle} ({tag.Type})"); + } + } + + [TestMethod] + public async Task GetItemAsync_WithShowElements_ReturnsContentWithElements() + { + // Get a valid item ID + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "music", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } + }); + + var itemId = searchResult.Results.First().Id; + + // Request with elements + var result = await ApiClient.GetItemAsync(itemId, + new GuardianApiContentAdditionalInformationOptions + { + ShowElements = [ShowElementsOption.Image, ShowElementsOption.Video] + }); + + result.ShouldNotBeNull(); + // Elements might be null if the article has no media, so we don't assert their presence + + Console.WriteLine($"Content '{result.Content.WebTitle}' elements:"); + if (result.Content.Elements != null) + { + Console.WriteLine($" Has {result.Content.Elements.Count} media elements"); + } + else + { + Console.WriteLine(" No media elements found"); + } + } + + [TestMethod] + public async Task GetItemAsync_WithShowBlocks_ReturnsContentWithBlocks() + { + // Get a valid item ID + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "news", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } + }); + + var itemId = searchResult.Results.First().Id; + + // Request with blocks using string array (as it's still string-based) + var result = await ApiClient.GetItemAsync(itemId, + new GuardianApiContentAdditionalInformationOptions + { + ShowBlocks = ["main", "body"] + }); + + result.ShouldNotBeNull(); + // Blocks might be null depending on content type + + Console.WriteLine($"Content '{result.Content.WebTitle}' blocks:"); + if (result.Content.Blocks != null) + { + Console.WriteLine($" Requested main and body blocks"); + } + else + { + Console.WriteLine(" No blocks found"); + } + } + + [TestMethod] + public async Task GetItemAsync_WithAllOptions_ReturnsFullyEnhancedContent() + { + // Get a valid item ID + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "culture", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } + }); + + var itemId = searchResult.Results.First().Id; + + // Request with all enhancement options + var result = await ApiClient.GetItemAsync(itemId, + new GuardianApiContentAdditionalInformationOptions + { + ShowFields = [ShowFieldsOption.All], + ShowTags = [ShowTagsOption.All], + ShowElements = [ShowElementsOption.All], + ShowBlocks = ["all"] + }); + + result.ShouldNotBeNull(); + result.Content.Fields.ShouldNotBeNull("All fields should be populated"); + result.Content.Tags.ShouldNotBeNull("All tags should be populated"); + + Console.WriteLine($"Fully enhanced content: {result.Content.WebTitle}"); + Console.WriteLine($" Fields populated: Yes"); + Console.WriteLine($" Tags count: {result.Content.Tags.Count}"); + Console.WriteLine($" Elements: {(result.Content.Elements?.Count ?? 0)}"); + Console.WriteLine($" Has blocks: {result.Content.Blocks != null}"); + } +} diff --git a/GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs b/GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs new file mode 100644 index 0000000..30cef0b --- /dev/null +++ b/GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs @@ -0,0 +1,136 @@ +using GuardianClient.Options.Search; +using Shouldly; + +namespace GuardianClient.Tests; + +[TestClass] +public class GuardianApiClientIntegrationTests : TestBase +{ + [TestMethod] + public void ApiClient_ShouldNotBeNull() + { + ApiClient.ShouldNotBeNull("API client should be properly initialized"); + ApiClient.ShouldBeAssignableTo("API client should implement the interface"); + } + + [TestMethod] + public async Task EndToEnd_SearchAndGetItem_WorksTogether() + { + // Search for content + var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "artificial intelligence", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 }, + FilterOptions = new GuardianApiContentFilterOptions + { + Section = "technology" + } + }); + + searchResult.ShouldNotBeNull("Search should return results"); + searchResult.Results.Count.ShouldBe(1, "Should return exactly one result"); + + // Get the specific item with enhanced information + var contentItem = searchResult.Results.First(); + var detailedResult = await ApiClient.GetItemAsync(contentItem.Id, + new GuardianApiContentAdditionalInformationOptions + { + ShowFields = [ShowFieldsOption.Headline, ShowFieldsOption.Body], + ShowTags = [ShowTagsOption.Keyword] + }); + + detailedResult.ShouldNotBeNull("Detailed result should not be null"); + detailedResult.Content!.Id.ShouldBe(contentItem.Id, "IDs should match"); + detailedResult.Content.WebTitle.ShouldBe(contentItem.WebTitle, "Titles should match"); + detailedResult.Content.Fields.ShouldNotBeNull("Detailed fields should be populated"); + detailedResult.Content.Tags.ShouldNotBeNull("Tags should be populated"); + + Console.WriteLine($"End-to-end test successful:"); + Console.WriteLine($" Search found: {contentItem.WebTitle}"); + Console.WriteLine($" Retrieved same item with {detailedResult.Content.Tags.Count} tags"); + Console.WriteLine($" Body content length: {detailedResult.Content.Fields.Body?.Length ?? 0} characters"); + } + + [TestMethod] + public async Task SearchWithComplexOptions_ReturnsExpectedResults() + { + // Test a complex search with multiple option types + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "climate change", + QueryFields = ["body", "headline"], + FilterOptions = new GuardianApiContentFilterOptions + { + Section = "environment" + }, + DateOptions = new GuardianApiContentDateOptions + { + FromDate = new DateOnly(2023, 1, 1) + }, + PageOptions = new GuardianApiContentPageOptions + { + Page = 1, + PageSize = 5 + }, + OrderOptions = new GuardianApiContentOrderOptions + { + OrderBy = GuardianApiContentOrderBy.Relevance + }, + AdditionalInformationOptions = new GuardianApiContentAdditionalInformationOptions + { + ShowFields = [ShowFieldsOption.Headline, ShowFieldsOption.Score], + ShowTags = [ShowTagsOption.Tone] + } + }); + + result.ShouldNotBeNull("Complex search should return results"); + result.Status.ShouldBe("ok", "API should respond successfully"); + result.Results.Count.ShouldBeLessThanOrEqualTo(5, "Should respect page size"); + + // Verify enhanced data is present + if (result.Results.Any()) + { + var firstItem = result.Results.First(); + firstItem.Fields.ShouldNotBeNull("Enhanced fields should be present"); + firstItem.Tags.ShouldNotBeNull("Tags should be present"); + + Console.WriteLine($"Complex search returned {result.Results.Count} results"); + Console.WriteLine($"First result: {firstItem.WebTitle}"); + Console.WriteLine($"Relevance score: {firstItem.Fields.Score ?? "N/A"}"); + } + } + + [TestMethod] + public async Task TypeSafetyTest_EnumsMapToCorrectApiValues() + { + // This test ensures our enums map to the correct API values + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "test", + PageOptions = new GuardianApiContentPageOptions { PageSize = 1 }, + AdditionalInformationOptions = new GuardianApiContentAdditionalInformationOptions + { + ShowFields = + [ + ShowFieldsOption.Headline, + ShowFieldsOption.TrailText, + ShowFieldsOption.ShowInRelatedContent + ], + ShowTags = [ShowTagsOption.Tone, ShowTagsOption.Type], + ShowElements = [ShowElementsOption.Image] + } + }); + + result.ShouldNotBeNull("Type safety test should work"); + result.Status.ShouldBe("ok", "API should accept enum-mapped values"); + + if (result.Results.Any()) + { + var item = result.Results.First(); + Console.WriteLine($"Type safety test passed for: {item.WebTitle}"); + Console.WriteLine($" Fields populated: {item.Fields != null}"); + Console.WriteLine($" Tags populated: {item.Tags != null}"); + Console.WriteLine($" Elements populated: {item.Elements != null}"); + } + } +} diff --git a/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs b/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs deleted file mode 100644 index 3a1a820..0000000 --- a/GuardianClient/GuardianClient.Tests/GuardianApiClientTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using GuardianClient.Options.Search; - -namespace GuardianClient.Tests; - -using Shouldly; - -[TestClass] -public class GuardianApiClientTests : TestBase -{ - [TestMethod] - public async Task SearchAsyncSmokeTest() - { - var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions - { - Query = "climate change", - PageOptions = new GuardianApiContentPageOptions { PageSize = 5 } - }); - - result.ShouldNotBeNull("Search result should not be null"); - result.Status.ShouldBe("ok", "API response status should be 'ok'"); - result.Results.Count.ShouldBeGreaterThan(0, "Should return at least one result"); - result.Results.Count.ShouldBeLessThanOrEqualTo(5, "Should not return more than requested page size"); - - var firstItem = result.Results.First(); - firstItem.Id.ShouldNotBeNullOrEmpty("Content item should have an ID"); - firstItem.WebTitle.ShouldNotBeNullOrEmpty("Content item should have a title"); - firstItem.WebUrl.ShouldNotBeNullOrEmpty("Content item should have a web URL"); - firstItem.ApiUrl.ShouldNotBeNullOrEmpty("Content item should have an API URL"); - - Console.WriteLine($"Found {result.Results.Count} articles about climate change"); - Console.WriteLine($"First article: {firstItem.WebTitle}"); - Console.WriteLine($"Published: {firstItem.WebPublicationDate}"); - } - - [TestMethod] - public async Task SearchAsyncWithNoResults() - { - var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions - { - Query = "xyzabc123nonexistentquery456" - }); - - result.ShouldNotBeNull("Search result should not be null even with no matches"); - result.Status.ShouldBe("ok", "API response status should be 'ok'"); - result.Results.Count.ShouldBe(0, "Should return zero results for non-existent query"); - } - - [TestMethod] - public async Task GetItemAsyncSmokeTest() - { - var searchResult = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions - { - Query = "technology", - PageOptions = new GuardianApiContentPageOptions { PageSize = 1 } - }); - searchResult.ShouldNotBeNull("Search should return results"); - searchResult.Results.Count.ShouldBe(1, "Should return exactly one result"); - - var contentItem = searchResult.Results.First(); - var itemId = contentItem.Id; - var singleItemResult = await ApiClient.GetItemAsync(itemId, - new GuardianApiContentAdditionalInformationOptions { ShowFields = [ShowFieldsOption.Body] }); - - singleItemResult.ShouldNotBeNull("GetItem result should not be null"); - singleItemResult.Status.ShouldBe("ok", "API response status should be 'ok'"); - singleItemResult.Content.ShouldNotBeNull("Content should not be null"); - singleItemResult.Content.Id.ShouldBe(itemId, "Returned content ID should match requested ID"); - singleItemResult.Content.WebTitle.ShouldNotBeNullOrEmpty("Content should have a title"); - singleItemResult.Content.Fields.ShouldNotBeNull("Fields should be populated when ShowFields is specified"); - - Console.WriteLine($"Retrieved item: {singleItemResult.Content.WebTitle}"); - Console.WriteLine($"Item ID: {singleItemResult.Content.Id}"); - Console.WriteLine($"Published: {singleItemResult.Content.WebPublicationDate}"); - - if (!string.IsNullOrEmpty(singleItemResult.Content.Fields.Body)) - { - Console.WriteLine($"Body length: {singleItemResult.Content.Fields.Body.Length} characters"); - Console.WriteLine( - $"Body preview: {singleItemResult.Content.Fields.Body[..Math.Min(200, singleItemResult.Content.Fields.Body.Length)]}..."); - } - } -} diff --git a/GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs b/GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs new file mode 100644 index 0000000..a346136 --- /dev/null +++ b/GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs @@ -0,0 +1,144 @@ +using GuardianClient.Options.Search; +using Shouldly; + +namespace GuardianClient.Tests; + +[TestClass] +public class SearchAsyncTests : TestBase +{ + [TestMethod] + public async Task SearchAsync_WithQuery_ReturnsResults() + { + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "climate change", + PageOptions = new GuardianApiContentPageOptions { PageSize = 5 } + }); + + result.ShouldNotBeNull("Search result should not be null"); + result.Status.ShouldBe("ok", "API response status should be 'ok'"); + result.Results.Count.ShouldBeGreaterThan(0, "Should return at least one result"); + result.Results.Count.ShouldBeLessThanOrEqualTo(5, "Should not return more than requested page size"); + + var firstItem = result.Results.First(); + firstItem.Id.ShouldNotBeNullOrEmpty("Content item should have an ID"); + firstItem.WebTitle.ShouldNotBeNullOrEmpty("Content item should have a title"); + firstItem.WebUrl.ShouldNotBeNullOrEmpty("Content item should have a web URL"); + firstItem.ApiUrl.ShouldNotBeNullOrEmpty("Content item should have an API URL"); + + Console.WriteLine($"Found {result.Results.Count} articles about climate change"); + Console.WriteLine($"First article: {firstItem.WebTitle}"); + Console.WriteLine($"Published: {firstItem.WebPublicationDate}"); + } + + [TestMethod] + public async Task SearchAsync_WithNonExistentQuery_ReturnsNoResults() + { + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "xyzabc123nonexistentquery456" + }); + + result.ShouldNotBeNull("Search result should not be null even with no matches"); + result.Status.ShouldBe("ok", "API response status should be 'ok'"); + result.Results.Count.ShouldBe(0, "Should return zero results for non-existent query"); + } + + [TestMethod] + public async Task SearchAsync_WithNoOptions_ReturnsDefaultResults() + { + var result = await ApiClient.SearchAsync(); + + result.ShouldNotBeNull("Search result should not be null"); + result.Status.ShouldBe("ok", "API response status should be 'ok'"); + result.Results.Count.ShouldBeGreaterThan(0, "Should return results with default options"); + result.Results.Count.ShouldBeLessThanOrEqualTo(10, "Default page size should be 10 or less"); + + Console.WriteLine($"Default search returned {result.Results.Count} results"); + } + + [TestMethod] + public async Task SearchAsync_WithFilterOptions_ReturnsFilteredResults() + { + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "technology", + FilterOptions = new GuardianApiContentFilterOptions + { + Section = "technology" + }, + PageOptions = new GuardianApiContentPageOptions { PageSize = 3 } + }); + + result.ShouldNotBeNull("Search result should not be null"); + result.Status.ShouldBe("ok", "API response status should be 'ok'"); + result.Results.Count.ShouldBeGreaterThan(0, "Should return technology results"); + + // Check that results are from technology section + foreach (var item in result.Results) + { + item.SectionId.ShouldBe("technology", "All results should be from technology section"); + } + + Console.WriteLine($"Found {result.Results.Count} technology articles"); + } + + [TestMethod] + public async Task SearchAsync_WithOrderOptions_ReturnsOrderedResults() + { + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "sports", + OrderOptions = new GuardianApiContentOrderOptions + { + OrderBy = GuardianApiContentOrderBy.Oldest + }, + PageOptions = new GuardianApiContentPageOptions { PageSize = 2 } + }); + + result.ShouldNotBeNull("Search result should not be null"); + result.Status.ShouldBe("ok", "API response status should be 'ok'"); + result.Results.Count.ShouldBeGreaterThan(0, "Should return sports results"); + + // Oldest first means first result should be older than or equal to second + if (result.Results.Count >= 2) + { + var first = result.Results.First(); + var second = result.Results.Skip(1).First(); + + if (first.WebPublicationDate.HasValue && second.WebPublicationDate.HasValue) + { + first.WebPublicationDate.Value.ShouldBeLessThanOrEqualTo(second.WebPublicationDate.Value, + "Results should be ordered oldest first"); + } + } + + Console.WriteLine($"Found {result.Results.Count} sports articles (oldest first)"); + } + + [TestMethod] + public async Task SearchAsync_WithAdditionalInformation_ReturnsEnhancedResults() + { + var result = await ApiClient.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "environment", + PageOptions = new GuardianApiContentPageOptions { PageSize = 2 }, + AdditionalInformationOptions = new GuardianApiContentAdditionalInformationOptions + { + ShowFields = [ShowFieldsOption.Headline, ShowFieldsOption.Thumbnail], + ShowTags = [ShowTagsOption.Keyword, ShowTagsOption.Tone] + } + }); + + result.ShouldNotBeNull("Search result should not be null"); + result.Status.ShouldBe("ok", "API response status should be 'ok'"); + result.Results.Count.ShouldBeGreaterThan(0, "Should return environment results"); + + var firstItem = result.Results.First(); + firstItem.Fields.ShouldNotBeNull("Fields should be populated"); + firstItem.Tags.ShouldNotBeNull("Tags should be populated"); + + Console.WriteLine($"Enhanced search returned {result.Results.Count} environment articles"); + Console.WriteLine($"First article has {firstItem.Tags.Count} tags"); + } +} diff --git a/GuardianClient/GuardianClient/GuardianClient.csproj b/GuardianClient/GuardianClient/GuardianClient.csproj index e09c9ec..b385d91 100644 --- a/GuardianClient/GuardianClient/GuardianClient.csproj +++ b/GuardianClient/GuardianClient/GuardianClient.csproj @@ -7,13 +7,13 @@ GuardianClient.NET - 0.3.0-alpha + 0.4.0-alpha Andrew Tarr A .NET API wrapper for Guardian services guardian;api;client;wrapper MIT https://github.com/tarrball/GuardianClient.NET - https://github.com/tarrball/GuardianClient.NET.git + https://github.com/tarrball/GuardianClient.NET git main README.md diff --git a/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs b/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs index fef571f..73981d1 100644 --- a/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs +++ b/GuardianClient/GuardianClient/Internal/UrlParameterBuilder.cs @@ -90,9 +90,9 @@ internal static void AddOrderParameters(GuardianApiContentOrderOptions orderOpti { var orderByValue = orderOptions.OrderBy.Value switch { - GuardianApiOrderBy.Newest => "newest", - GuardianApiOrderBy.Oldest => "oldest", - GuardianApiOrderBy.Relevance => "relevance", + GuardianApiContentOrderBy.Newest => "newest", + GuardianApiContentOrderBy.Oldest => "oldest", + GuardianApiContentOrderBy.Relevance => "relevance", _ => "newest" }; parameters.Add($"order-by={orderByValue}"); @@ -102,9 +102,9 @@ internal static void AddOrderParameters(GuardianApiContentOrderOptions orderOpti { var orderDateValue = orderOptions.OrderDate.Value switch { - GuardianApiOrderDate.Published => "published", - GuardianApiOrderDate.NewspaperEdition => "newspaper-edition", - GuardianApiOrderDate.LastModified => "last-modified", + GuardianApiContentOrderDate.Published => "published", + GuardianApiContentOrderDate.NewspaperEdition => "newspaper-edition", + GuardianApiContentOrderDate.LastModified => "last-modified", _ => "published" }; parameters.Add($"order-date={orderDateValue}"); diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderBy.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderBy.cs similarity index 92% rename from GuardianClient/GuardianClient/Options/Search/GuardianApiOrderBy.cs rename to GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderBy.cs index f5ee5a8..b75c022 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderBy.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderBy.cs @@ -3,7 +3,7 @@ namespace GuardianClient.Options.Search; /// /// Specifies the order in which search results should be returned. /// -public enum GuardianApiOrderBy +public enum GuardianApiContentOrderBy { /// /// Order by newest content first. Default in all other cases. diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderDate.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderDate.cs similarity index 91% rename from GuardianClient/GuardianClient/Options/Search/GuardianApiOrderDate.cs rename to GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderDate.cs index 31d89e6..10962a8 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiOrderDate.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderDate.cs @@ -3,7 +3,7 @@ namespace GuardianClient.Options.Search; /// /// Specifies which type of date is used to order the results. /// -public enum GuardianApiOrderDate +public enum GuardianApiContentOrderDate { /// /// The date the content appeared on the web. Default. diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs index 2fd0143..606a557 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentOrderOptions.cs @@ -11,10 +11,10 @@ public class GuardianApiContentOrderOptions /// /// Returns results in the specified order. Defaults to Newest in most cases, or Relevance when a query parameter is specified. /// - public GuardianApiOrderBy? OrderBy { get; set; } + public GuardianApiContentOrderBy? OrderBy { get; set; } /// /// Changes which type of date is used to order the results. Defaults to Published. /// - public GuardianApiOrderDate? OrderDate { get; set; } + public GuardianApiContentOrderDate? OrderDate { get; set; } } From 3ab1f62eb4cc9582e5d65c0f930669ded2e1be12 Mon Sep 17 00:00:00 2001 From: Andrew Tarr Date: Mon, 11 Aug 2025 20:48:49 -0500 Subject: [PATCH 6/7] naming and test updates --- .../GuardianClient.Tests/GetItemAsyncTests.cs | 20 +- .../GuardianApiClientIntegrationTests.cs | 62 ++++-- .../GuardianClient.Tests/SearchAsyncTests.cs | 4 +- .../AdditionalInformationExtensions.cs | 95 +++++++++ .../Search/AdditionalInformationEnums.cs | 189 ------------------ ...nApiContentAdditionalInformationOptions.cs | 8 +- .../GuardianApiContentShowElementsOption.cs | 12 ++ .../GuardianApiContentShowFieldsOption.cs | 31 +++ .../GuardianApiContentShowReferencesOption.cs | 29 +++ .../GuardianApiContentShowRightsOption.cs | 11 + .../GuardianApiContentShowTagsOption.cs | 18 ++ 11 files changed, 261 insertions(+), 218 deletions(-) create mode 100644 GuardianClient/GuardianClient/Internal/AdditionalInformationExtensions.cs delete mode 100644 GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowElementsOption.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowFieldsOption.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowReferencesOption.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowRightsOption.cs create mode 100644 GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowTagsOption.cs diff --git a/GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs b/GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs index a0dcbe5..d34547c 100644 --- a/GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs +++ b/GuardianClient/GuardianClient.Tests/GetItemAsyncTests.cs @@ -23,7 +23,7 @@ public async Task GetItemAsync_WithValidId_ReturnsItem() // Now get the specific item var singleItemResult = await ApiClient.GetItemAsync(itemId, - new GuardianApiContentAdditionalInformationOptions { ShowFields = [ShowFieldsOption.Body] }); + new GuardianApiContentAdditionalInformationOptions { ShowFields = [GuardianApiContentShowFieldsOption.Body] }); singleItemResult.ShouldNotBeNull("GetItem result should not be null"); singleItemResult.Status.ShouldBe("ok", "API response status should be 'ok'"); @@ -97,10 +97,10 @@ public async Task GetItemAsync_WithShowFields_ReturnsEnhancedContent() { ShowFields = [ - ShowFieldsOption.Headline, - ShowFieldsOption.Body, - ShowFieldsOption.Byline, - ShowFieldsOption.Thumbnail + GuardianApiContentShowFieldsOption.Headline, + GuardianApiContentShowFieldsOption.Body, + GuardianApiContentShowFieldsOption.Byline, + GuardianApiContentShowFieldsOption.Thumbnail ] }); @@ -130,7 +130,7 @@ public async Task GetItemAsync_WithShowTags_ReturnsContentWithTags() var result = await ApiClient.GetItemAsync(itemId, new GuardianApiContentAdditionalInformationOptions { - ShowTags = [ShowTagsOption.Keyword, ShowTagsOption.Tone, ShowTagsOption.Type] + ShowTags = [GuardianApiContentShowTagsOption.Keyword, GuardianApiContentShowTagsOption.Tone, GuardianApiContentShowTagsOption.Type] }); result.ShouldNotBeNull(); @@ -160,7 +160,7 @@ public async Task GetItemAsync_WithShowElements_ReturnsContentWithElements() var result = await ApiClient.GetItemAsync(itemId, new GuardianApiContentAdditionalInformationOptions { - ShowElements = [ShowElementsOption.Image, ShowElementsOption.Video] + ShowElements = [GuardianApiContentShowElementsOption.Image, GuardianApiContentShowElementsOption.Video] }); result.ShouldNotBeNull(); @@ -226,9 +226,9 @@ public async Task GetItemAsync_WithAllOptions_ReturnsFullyEnhancedContent() var result = await ApiClient.GetItemAsync(itemId, new GuardianApiContentAdditionalInformationOptions { - ShowFields = [ShowFieldsOption.All], - ShowTags = [ShowTagsOption.All], - ShowElements = [ShowElementsOption.All], + ShowFields = [GuardianApiContentShowFieldsOption.All], + ShowTags = [GuardianApiContentShowTagsOption.All], + ShowElements = [GuardianApiContentShowElementsOption.All], ShowBlocks = ["all"] }); diff --git a/GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs b/GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs index 30cef0b..22bcba9 100644 --- a/GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs +++ b/GuardianClient/GuardianClient.Tests/GuardianApiClientIntegrationTests.cs @@ -35,8 +35,8 @@ public async Task EndToEnd_SearchAndGetItem_WorksTogether() var detailedResult = await ApiClient.GetItemAsync(contentItem.Id, new GuardianApiContentAdditionalInformationOptions { - ShowFields = [ShowFieldsOption.Headline, ShowFieldsOption.Body], - ShowTags = [ShowTagsOption.Keyword] + ShowFields = [GuardianApiContentShowFieldsOption.Headline, GuardianApiContentShowFieldsOption.Body], + ShowTags = [GuardianApiContentShowTagsOption.Keyword] }); detailedResult.ShouldNotBeNull("Detailed result should not be null"); @@ -45,10 +45,46 @@ public async Task EndToEnd_SearchAndGetItem_WorksTogether() detailedResult.Content.Fields.ShouldNotBeNull("Detailed fields should be populated"); detailedResult.Content.Tags.ShouldNotBeNull("Tags should be populated"); - Console.WriteLine($"End-to-end test successful:"); - Console.WriteLine($" Search found: {contentItem.WebTitle}"); - Console.WriteLine($" Retrieved same item with {detailedResult.Content.Tags.Count} tags"); - Console.WriteLine($" Body content length: {detailedResult.Content.Fields.Body?.Length ?? 0} characters"); + Console.WriteLine("=== END-TO-END TEST RESULTS ==="); + Console.WriteLine(); + Console.WriteLine($"SEARCH RESULT:"); + Console.WriteLine($" Title: {contentItem.WebTitle}"); + Console.WriteLine($" Section: {contentItem.SectionName} ({contentItem.SectionId})"); + Console.WriteLine($" Published: {contentItem.WebPublicationDate}"); + Console.WriteLine($" URL: {contentItem.WebUrl}"); + Console.WriteLine(); + + Console.WriteLine($"DETAILED CONTENT:"); + Console.WriteLine($" ID: {detailedResult.Content.Id}"); + Console.WriteLine($" Headline: {detailedResult.Content.Fields.Headline ?? "N/A"}"); + Console.WriteLine($" Tags: {detailedResult.Content.Tags.Count} tags"); + + if (detailedResult.Content.Tags.Any()) + { + Console.WriteLine($" Tag List:"); + foreach (var tag in detailedResult.Content.Tags.Take(10)) // Show first 10 tags + { + Console.WriteLine($" - {tag.WebTitle} ({tag.Type})"); + } + if (detailedResult.Content.Tags.Count > 10) + { + Console.WriteLine($" ... and {detailedResult.Content.Tags.Count - 10} more tags"); + } + } + + Console.WriteLine(); + Console.WriteLine($"FULL ARTICLE BODY:"); + Console.WriteLine("=================="); + if (!string.IsNullOrEmpty(detailedResult.Content.Fields.Body)) + { + Console.WriteLine(detailedResult.Content.Fields.Body); + } + else + { + Console.WriteLine("No body content available"); + } + Console.WriteLine("=================="); + Console.WriteLine(); } [TestMethod] @@ -78,8 +114,8 @@ public async Task SearchWithComplexOptions_ReturnsExpectedResults() }, AdditionalInformationOptions = new GuardianApiContentAdditionalInformationOptions { - ShowFields = [ShowFieldsOption.Headline, ShowFieldsOption.Score], - ShowTags = [ShowTagsOption.Tone] + ShowFields = [GuardianApiContentShowFieldsOption.Headline, GuardianApiContentShowFieldsOption.Score], + ShowTags = [GuardianApiContentShowTagsOption.Tone] } }); @@ -112,12 +148,12 @@ public async Task TypeSafetyTest_EnumsMapToCorrectApiValues() { ShowFields = [ - ShowFieldsOption.Headline, - ShowFieldsOption.TrailText, - ShowFieldsOption.ShowInRelatedContent + GuardianApiContentShowFieldsOption.Headline, + GuardianApiContentShowFieldsOption.TrailText, + GuardianApiContentShowFieldsOption.ShowInRelatedContent ], - ShowTags = [ShowTagsOption.Tone, ShowTagsOption.Type], - ShowElements = [ShowElementsOption.Image] + ShowTags = [GuardianApiContentShowTagsOption.Tone, GuardianApiContentShowTagsOption.Type], + ShowElements = [GuardianApiContentShowElementsOption.Image] } }); diff --git a/GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs b/GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs index a346136..02bfbb4 100644 --- a/GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs +++ b/GuardianClient/GuardianClient.Tests/SearchAsyncTests.cs @@ -125,8 +125,8 @@ public async Task SearchAsync_WithAdditionalInformation_ReturnsEnhancedResults() PageOptions = new GuardianApiContentPageOptions { PageSize = 2 }, AdditionalInformationOptions = new GuardianApiContentAdditionalInformationOptions { - ShowFields = [ShowFieldsOption.Headline, ShowFieldsOption.Thumbnail], - ShowTags = [ShowTagsOption.Keyword, ShowTagsOption.Tone] + ShowFields = [GuardianApiContentShowFieldsOption.Headline, GuardianApiContentShowFieldsOption.Thumbnail], + ShowTags = [GuardianApiContentShowTagsOption.Keyword, GuardianApiContentShowTagsOption.Tone] } }); diff --git a/GuardianClient/GuardianClient/Internal/AdditionalInformationExtensions.cs b/GuardianClient/GuardianClient/Internal/AdditionalInformationExtensions.cs new file mode 100644 index 0000000..0327232 --- /dev/null +++ b/GuardianClient/GuardianClient/Internal/AdditionalInformationExtensions.cs @@ -0,0 +1,95 @@ +using GuardianClient.Options.Search; + +namespace GuardianClient.Internal; + +/// +/// Extension methods for converting additional information enums to their API string values. +/// +internal static class AdditionalInformationExtensions +{ + internal static string ToApiString(this GuardianApiContentShowFieldsOption option) => option switch + { + GuardianApiContentShowFieldsOption.TrailText => "trailText", + GuardianApiContentShowFieldsOption.Headline => "headline", + GuardianApiContentShowFieldsOption.ShowInRelatedContent => "showInRelatedContent", + GuardianApiContentShowFieldsOption.Body => "body", + GuardianApiContentShowFieldsOption.LastModified => "lastModified", + GuardianApiContentShowFieldsOption.HasStoryPackage => "hasStoryPackage", + GuardianApiContentShowFieldsOption.Score => "score", + GuardianApiContentShowFieldsOption.Standfirst => "standfirst", + GuardianApiContentShowFieldsOption.ShortUrl => "shortUrl", + GuardianApiContentShowFieldsOption.Thumbnail => "thumbnail", + GuardianApiContentShowFieldsOption.Wordcount => "wordcount", + GuardianApiContentShowFieldsOption.Commentable => "commentable", + GuardianApiContentShowFieldsOption.IsPremoderated => "isPremoderated", + GuardianApiContentShowFieldsOption.AllowUgc => "allowUgc", + GuardianApiContentShowFieldsOption.Byline => "byline", + GuardianApiContentShowFieldsOption.Publication => "publication", + GuardianApiContentShowFieldsOption.InternalPageCode => "internalPageCode", + GuardianApiContentShowFieldsOption.ProductionOffice => "productionOffice", + GuardianApiContentShowFieldsOption.ShouldHideAdverts => "shouldHideAdverts", + GuardianApiContentShowFieldsOption.LiveBloggingNow => "liveBloggingNow", + GuardianApiContentShowFieldsOption.CommentCloseDate => "commentCloseDate", + GuardianApiContentShowFieldsOption.StarRating => "starRating", + GuardianApiContentShowFieldsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this GuardianApiContentShowTagsOption option) => option switch + { + GuardianApiContentShowTagsOption.Blog => "blog", + GuardianApiContentShowTagsOption.Contributor => "contributor", + GuardianApiContentShowTagsOption.Keyword => "keyword", + GuardianApiContentShowTagsOption.NewspaperBook => "newspaper-book", + GuardianApiContentShowTagsOption.NewspaperBookSection => "newspaper-book-section", + GuardianApiContentShowTagsOption.Publication => "publication", + GuardianApiContentShowTagsOption.Series => "series", + GuardianApiContentShowTagsOption.Tone => "tone", + GuardianApiContentShowTagsOption.Type => "type", + GuardianApiContentShowTagsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this GuardianApiContentShowElementsOption option) => option switch + { + GuardianApiContentShowElementsOption.Audio => "audio", + GuardianApiContentShowElementsOption.Image => "image", + GuardianApiContentShowElementsOption.Video => "video", + GuardianApiContentShowElementsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this GuardianApiContentShowReferencesOption option) => option switch + { + GuardianApiContentShowReferencesOption.Author => "author", + GuardianApiContentShowReferencesOption.BisacPrefix => "bisac-prefix", + GuardianApiContentShowReferencesOption.EsaCricketMatch => "esa-cricket-match", + GuardianApiContentShowReferencesOption.EsaFootballMatch => "esa-football-match", + GuardianApiContentShowReferencesOption.EsaFootballTeam => "esa-football-team", + GuardianApiContentShowReferencesOption.EsaFootballTournament => "esa-football-tournament", + GuardianApiContentShowReferencesOption.Isbn => "isbn", + GuardianApiContentShowReferencesOption.Imdb => "imdb", + GuardianApiContentShowReferencesOption.Musicbrainz => "musicbrainz", + GuardianApiContentShowReferencesOption.MusicbrainzGenre => "musicbrainzgenre", + GuardianApiContentShowReferencesOption.OptaCricketMatch => "opta-cricket-match", + GuardianApiContentShowReferencesOption.OptaFootballMatch => "opta-football-match", + GuardianApiContentShowReferencesOption.OptaFootballTeam => "opta-football-team", + GuardianApiContentShowReferencesOption.OptaFootballTournament => "opta-football-tournament", + GuardianApiContentShowReferencesOption.PaFootballCompetition => "pa-football-competition", + GuardianApiContentShowReferencesOption.PaFootballMatch => "pa-football-match", + GuardianApiContentShowReferencesOption.PaFootballTeam => "pa-football-team", + GuardianApiContentShowReferencesOption.R1Film => "r1-film", + GuardianApiContentShowReferencesOption.ReutersIndexRic => "reuters-index-ric", + GuardianApiContentShowReferencesOption.ReutersStockRic => "reuters-stock-ric", + GuardianApiContentShowReferencesOption.WitnessAssignment => "witness-assignment", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; + + internal static string ToApiString(this GuardianApiContentShowRightsOption option) => option switch + { + GuardianApiContentShowRightsOption.Syndicatable => "syndicatable", + GuardianApiContentShowRightsOption.SubscriptionDatabases => "subscription-databases", + GuardianApiContentShowRightsOption.All => "all", + _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) + }; +} diff --git a/GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs b/GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs deleted file mode 100644 index b3dc492..0000000 --- a/GuardianClient/GuardianClient/Options/Search/AdditionalInformationEnums.cs +++ /dev/null @@ -1,189 +0,0 @@ -namespace GuardianClient.Options.Search; - -/// -/// Fields that can be included with content responses. -/// -public enum ShowFieldsOption -{ - TrailText, - Headline, - ShowInRelatedContent, - Body, - LastModified, - HasStoryPackage, - Score, - Standfirst, - ShortUrl, - Thumbnail, - Wordcount, - Commentable, - IsPremoderated, - AllowUgc, - Byline, - Publication, - InternalPageCode, - ProductionOffice, - ShouldHideAdverts, - LiveBloggingNow, - CommentCloseDate, - StarRating, - All -} - -/// -/// Tag types that can be included with content responses. -/// -public enum ShowTagsOption -{ - Blog, - Contributor, - Keyword, - NewspaperBook, - NewspaperBookSection, - Publication, - Series, - Tone, - Type, - All -} - -/// -/// Media element types that can be included with content responses. -/// -public enum ShowElementsOption -{ - Audio, - Image, - Video, - All -} - -/// -/// Reference types that can be included with content responses. -/// -public enum ShowReferencesOption -{ - Author, - BisacPrefix, - EsaCricketMatch, - EsaFootballMatch, - EsaFootballTeam, - EsaFootballTournament, - Isbn, - Imdb, - Musicbrainz, - MusicbrainzGenre, - OptaCricketMatch, - OptaFootballMatch, - OptaFootballTeam, - OptaFootballTournament, - PaFootballCompetition, - PaFootballMatch, - PaFootballTeam, - R1Film, - ReutersIndexRic, - ReutersStockRic, - WitnessAssignment -} - -/// -/// Rights types that can be included with content responses. -/// -public enum ShowRightsOption -{ - Syndicatable, - SubscriptionDatabases, - All -} - -/// -/// Extension methods for converting additional information enums to their API string values. -/// -internal static class AdditionalInformationEnumExtensions -{ - internal static string ToApiString(this ShowFieldsOption option) => option switch - { - ShowFieldsOption.TrailText => "trailText", - ShowFieldsOption.Headline => "headline", - ShowFieldsOption.ShowInRelatedContent => "showInRelatedContent", - ShowFieldsOption.Body => "body", - ShowFieldsOption.LastModified => "lastModified", - ShowFieldsOption.HasStoryPackage => "hasStoryPackage", - ShowFieldsOption.Score => "score", - ShowFieldsOption.Standfirst => "standfirst", - ShowFieldsOption.ShortUrl => "shortUrl", - ShowFieldsOption.Thumbnail => "thumbnail", - ShowFieldsOption.Wordcount => "wordcount", - ShowFieldsOption.Commentable => "commentable", - ShowFieldsOption.IsPremoderated => "isPremoderated", - ShowFieldsOption.AllowUgc => "allowUgc", - ShowFieldsOption.Byline => "byline", - ShowFieldsOption.Publication => "publication", - ShowFieldsOption.InternalPageCode => "internalPageCode", - ShowFieldsOption.ProductionOffice => "productionOffice", - ShowFieldsOption.ShouldHideAdverts => "shouldHideAdverts", - ShowFieldsOption.LiveBloggingNow => "liveBloggingNow", - ShowFieldsOption.CommentCloseDate => "commentCloseDate", - ShowFieldsOption.StarRating => "starRating", - ShowFieldsOption.All => "all", - _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) - }; - - internal static string ToApiString(this ShowTagsOption option) => option switch - { - ShowTagsOption.Blog => "blog", - ShowTagsOption.Contributor => "contributor", - ShowTagsOption.Keyword => "keyword", - ShowTagsOption.NewspaperBook => "newspaper-book", - ShowTagsOption.NewspaperBookSection => "newspaper-book-section", - ShowTagsOption.Publication => "publication", - ShowTagsOption.Series => "series", - ShowTagsOption.Tone => "tone", - ShowTagsOption.Type => "type", - ShowTagsOption.All => "all", - _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) - }; - - internal static string ToApiString(this ShowElementsOption option) => option switch - { - ShowElementsOption.Audio => "audio", - ShowElementsOption.Image => "image", - ShowElementsOption.Video => "video", - ShowElementsOption.All => "all", - _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) - }; - - internal static string ToApiString(this ShowReferencesOption option) => option switch - { - ShowReferencesOption.Author => "author", - ShowReferencesOption.BisacPrefix => "bisac-prefix", - ShowReferencesOption.EsaCricketMatch => "esa-cricket-match", - ShowReferencesOption.EsaFootballMatch => "esa-football-match", - ShowReferencesOption.EsaFootballTeam => "esa-football-team", - ShowReferencesOption.EsaFootballTournament => "esa-football-tournament", - ShowReferencesOption.Isbn => "isbn", - ShowReferencesOption.Imdb => "imdb", - ShowReferencesOption.Musicbrainz => "musicbrainz", - ShowReferencesOption.MusicbrainzGenre => "musicbrainzgenre", - ShowReferencesOption.OptaCricketMatch => "opta-cricket-match", - ShowReferencesOption.OptaFootballMatch => "opta-football-match", - ShowReferencesOption.OptaFootballTeam => "opta-football-team", - ShowReferencesOption.OptaFootballTournament => "opta-football-tournament", - ShowReferencesOption.PaFootballCompetition => "pa-football-competition", - ShowReferencesOption.PaFootballMatch => "pa-football-match", - ShowReferencesOption.PaFootballTeam => "pa-football-team", - ShowReferencesOption.R1Film => "r1-film", - ShowReferencesOption.ReutersIndexRic => "reuters-index-ric", - ShowReferencesOption.ReutersStockRic => "reuters-stock-ric", - ShowReferencesOption.WitnessAssignment => "witness-assignment", - _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) - }; - - internal static string ToApiString(this ShowRightsOption option) => option switch - { - ShowRightsOption.Syndicatable => "syndicatable", - ShowRightsOption.SubscriptionDatabases => "subscription-databases", - ShowRightsOption.All => "all", - _ => throw new ArgumentOutOfRangeException(nameof(option), option, null) - }; -} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs index 22373f8..f0a4f81 100644 --- a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentAdditionalInformationOptions.cs @@ -11,22 +11,22 @@ public class GuardianApiContentAdditionalInformationOptions /// /// Add fields associated with the content such as headline, body, thumbnail, etc. /// - public ShowFieldsOption[]? ShowFields { get; set; } + public GuardianApiContentShowFieldsOption[]? ShowFields { get; set; } /// /// Add associated metadata tags such as contributor, keyword, tone, etc. /// - public ShowTagsOption[]? ShowTags { get; set; } + public GuardianApiContentShowTagsOption[]? ShowTags { get; set; } /// /// Add associated media elements such as images, audio, and video. /// - public ShowElementsOption[]? ShowElements { get; set; } + public GuardianApiContentShowElementsOption[]? ShowElements { get; set; } /// /// Add associated reference data such as ISBNs, IMDB IDs, author references, etc. /// - public ShowReferencesOption[]? ShowReferences { get; set; } + public GuardianApiContentShowReferencesOption[]? ShowReferences { get; set; } /// diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowElementsOption.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowElementsOption.cs new file mode 100644 index 0000000..275fe03 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowElementsOption.cs @@ -0,0 +1,12 @@ +namespace GuardianClient.Options.Search; + +/// +/// Media element types that can be included with content responses. +/// +public enum GuardianApiContentShowElementsOption +{ + Audio, + Image, + Video, + All +} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowFieldsOption.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowFieldsOption.cs new file mode 100644 index 0000000..32e0251 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowFieldsOption.cs @@ -0,0 +1,31 @@ +namespace GuardianClient.Options.Search; + +/// +/// Fields that can be included with content responses. +/// +public enum GuardianApiContentShowFieldsOption +{ + TrailText, + Headline, + ShowInRelatedContent, + Body, + LastModified, + HasStoryPackage, + Score, + Standfirst, + ShortUrl, + Thumbnail, + Wordcount, + Commentable, + IsPremoderated, + AllowUgc, + Byline, + Publication, + InternalPageCode, + ProductionOffice, + ShouldHideAdverts, + LiveBloggingNow, + CommentCloseDate, + StarRating, + All +} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowReferencesOption.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowReferencesOption.cs new file mode 100644 index 0000000..663bb09 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowReferencesOption.cs @@ -0,0 +1,29 @@ +namespace GuardianClient.Options.Search; + +/// +/// Reference types that can be included with content responses. +/// +public enum GuardianApiContentShowReferencesOption +{ + Author, + BisacPrefix, + EsaCricketMatch, + EsaFootballMatch, + EsaFootballTeam, + EsaFootballTournament, + Isbn, + Imdb, + Musicbrainz, + MusicbrainzGenre, + OptaCricketMatch, + OptaFootballMatch, + OptaFootballTeam, + OptaFootballTournament, + PaFootballCompetition, + PaFootballMatch, + PaFootballTeam, + R1Film, + ReutersIndexRic, + ReutersStockRic, + WitnessAssignment +} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowRightsOption.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowRightsOption.cs new file mode 100644 index 0000000..0a57c62 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowRightsOption.cs @@ -0,0 +1,11 @@ +namespace GuardianClient.Options.Search; + +/// +/// Rights types that can be included with content responses. +/// +public enum GuardianApiContentShowRightsOption +{ + Syndicatable, + SubscriptionDatabases, + All +} \ No newline at end of file diff --git a/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowTagsOption.cs b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowTagsOption.cs new file mode 100644 index 0000000..d186193 --- /dev/null +++ b/GuardianClient/GuardianClient/Options/Search/GuardianApiContentShowTagsOption.cs @@ -0,0 +1,18 @@ +namespace GuardianClient.Options.Search; + +/// +/// Tag types that can be included with content responses. +/// +public enum GuardianApiContentShowTagsOption +{ + Blog, + Contributor, + Keyword, + NewspaperBook, + NewspaperBookSection, + Publication, + Series, + Tone, + Type, + All +} \ No newline at end of file From 7a6249256fc4902bd6cd985a74858a3f6f403764 Mon Sep 17 00:00:00 2001 From: Andrew Tarr Date: Mon, 11 Aug 2025 20:53:18 -0500 Subject: [PATCH 7/7] README.md updates --- README.md | 190 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 163 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b8a74d2..8f78dc4 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,21 @@ Guardian knight logo

-A modern .NET client library for The Guardian's Content API. Provides strongly-typed models and simple methods for -searching Guardian articles, tags, sections, and more. +A modern, comprehensive .NET client library for The Guardian's Content API. Provides strongly-typed models, flexible search options, and simple methods for searching Guardian articles with full API feature support. [![NuGet Version](https://img.shields.io/nuget/v/GuardianClient.NET?logo=nuget)](https://www.nuget.org/packages/GuardianClient.NET) [![Build Status](https://img.shields.io/github/actions/workflow/status/tarrball/GuardianClient.NET/deploy-nuget.yml?branch=main)](https://github.com/tarrball/GuardianClient.NET/actions) ## โœจ Features -- ๐Ÿ” **Content Search** - Search Guardian articles with full query support -- ๐Ÿท๏ธ **Strongly Typed** - Complete C# models for all API responses -- ๐Ÿ”ง **Dependency Injection** - Easy setup with `services.AddGuardianApiClient()` -- ๐Ÿ“ฆ **HttpClientFactory** - Proper HttpClient lifecycle management -- โšก **Async/Await** - Modern async patterns with cancellation support +- ๐Ÿ” **Complete Content Search** - Full Guardian Content API search with all parameters +- ๐Ÿท๏ธ **Strongly Typed** - Type-safe enums for fields, tags, elements, and references +- ๐ŸŽฏ **Comprehensive Filtering** - Search by section, tags, date ranges, production office, and more +- ๐Ÿ“‘ **Rich Content Options** - Include fields, tags, media elements, blocks, and references +- ๐Ÿ”ง **Interface-Based Design** - Easy mocking and dependency injection with `IGuardianApiClient` +- ๐Ÿ“ฆ **HttpClientFactory Ready** - Proper HttpClient lifecycle management +- โšก **Modern Async** - Full async/await patterns with cancellation support +- ๐ŸŽจ **Clean API** - Organized option classes for maintainable code ## ๐Ÿš€ Quick Start @@ -28,37 +30,129 @@ searching Guardian articles, tags, sections, and more. dotnet add package GuardianClient.NET ``` -### Setup with Dependency Injection +### Setup with Dependency Injection (Recommended) ```csharp // Program.cs or Startup.cs builder.Services.AddGuardianApiClient("your-api-key"); ``` -### Usage +### Basic Usage ```csharp public class NewsService { - private readonly GuardianApiClient _client; + private readonly IGuardianApiClient _client; - public NewsService(GuardianApiClient client) + public NewsService(IGuardianApiClient client) { _client = client; } - public async Task GetLatestNews() + public async Task GetLatestTechNews() { - return await _client.SearchAsync("politics", pageSize: 10); + return await _client.SearchAsync(new GuardianApiContentSearchOptions + { + Query = "artificial intelligence", + FilterOptions = new GuardianApiContentFilterOptions + { + Section = "technology" + }, + PageOptions = new GuardianApiContentPageOptions + { + PageSize = 10 + } + }); } } ``` +## ๐Ÿ”ง Advanced Usage + +### Comprehensive Search with All Options + +```csharp +var results = await client.SearchAsync(new GuardianApiContentSearchOptions +{ + // Search terms with boolean operators + Query = "climate change AND (policy OR legislation)", + QueryFields = ["body", "headline"], + + // Filtering options + FilterOptions = new GuardianApiContentFilterOptions + { + Section = "environment", + Tag = "climate-change", + Language = "en" + }, + + // Date filtering + DateOptions = new GuardianApiContentDateOptions + { + FromDate = new DateOnly(2023, 1, 1), + UseDate = "published" + }, + + // Pagination + PageOptions = new GuardianApiContentPageOptions + { + Page = 1, + PageSize = 20 + }, + + // Ordering + OrderOptions = new GuardianApiContentOrderOptions + { + OrderBy = GuardianApiContentOrderBy.Relevance, + OrderDate = GuardianApiContentOrderDate.Published + }, + + // Additional content + AdditionalInformationOptions = new GuardianApiContentAdditionalInformationOptions + { + ShowFields = [ + GuardianApiContentShowFieldsOption.Headline, + GuardianApiContentShowFieldsOption.Body, + GuardianApiContentShowFieldsOption.Thumbnail + ], + ShowTags = [ + GuardianApiContentShowTagsOption.Keyword, + GuardianApiContentShowTagsOption.Tone + ], + ShowElements = [GuardianApiContentShowElementsOption.Image] + } +}); +``` + +### Getting Individual Articles + +```csharp +// Get a specific article by ID +var article = await client.GetItemAsync("world/2023/oct/15/climate-summit-agreement", + new GuardianApiContentAdditionalInformationOptions + { + ShowFields = [ + GuardianApiContentShowFieldsOption.Body, + GuardianApiContentShowFieldsOption.Byline + ], + ShowTags = [GuardianApiContentShowTagsOption.All] + }); + +// Access the rich content +Console.WriteLine(article.Content.WebTitle); +Console.WriteLine(article.Content.Fields.Body); // Full HTML content +Console.WriteLine($"Author: {article.Content.Fields.Byline}"); +``` + ### Manual Setup (without DI) ```csharp using var client = new GuardianApiClient("your-api-key"); -var results = await client.SearchAsync("climate change", pageSize: 5); +var results = await client.SearchAsync(new GuardianApiContentSearchOptions +{ + Query = "sports", + PageOptions = new GuardianApiContentPageOptions { PageSize = 5 } +}); foreach (var article in results.Results) { @@ -69,27 +163,69 @@ foreach (var article in results.Results) ## ๐Ÿ”‘ Getting an API Key 1. Visit [The Guardian Open Platform](https://open-platform.theguardian.com/access/) -2. Sign up for a free developer account +2. Sign up for a free developer account 3. Generate your API key +4. Store it securely (use User Secrets for development) + +## ๐Ÿ—๏ธ Available Options + +### Filter Options +- **Section**: Filter by Guardian sections (e.g., "politics", "sport", "culture") +- **Tags**: Filter by content tags with boolean operators +- **Date Range**: Filter by publication date with flexible date types +- **Language**: Filter by content language (ISO codes) +- **Production Office**: Filter by Guardian production offices +- **Star Rating**: Filter by review ratings (1-5) + +### Additional Content Options +- **ShowFields**: Include extra fields like body content, thumbnails, bylines +- **ShowTags**: Include metadata tags (keywords, contributors, tone) +- **ShowElements**: Include media elements (images, videos, audio) +- **ShowReferences**: Include reference data (ISBNs, IMDB IDs, etc.) +- **ShowBlocks**: Include content blocks (useful for live blogs) + +### Ordering Options +- **OrderBy**: Sort by newest, oldest, or relevance +- **OrderDate**: Choose which date to use for sorting -## ๐Ÿ“‹ Current Status +## ๐Ÿ“Š Current Status -**โœ… Implemented:** +**โœ… Fully Implemented:** +- Complete Content API search with all parameters +- Individual article retrieval +- Strongly-typed models and enums for all options +- Advanced filtering, pagination, and sorting +- Rich content enhancement options +- Interface-based design for easy testing +- Comprehensive documentation and examples -- Content search with basic parameters -- Strongly-typed response models -- Dependency injection support -- HttpClientFactory integration +**๐ŸŽฏ Feature Complete:** This library now supports the full Guardian Content API specification. -**๐Ÿ”„ In Development:** +## ๐Ÿงช Testing -- Additional endpoints (tags, sections, editions) -- Advanced search parameters -- Deep pagination support +The library includes comprehensive test coverage: + +```bash +# Run all tests +dotnet test + +# Run with detailed output +dotnet test --logger "console;verbosity=detailed" +``` + +Tests require a Guardian API key stored in user secrets: +```bash +dotnet user-secrets set "GuardianApiKey" "your-api-key-here" +``` ## ๐Ÿค Contributing -This is an early-stage project. Issues and pull requests are welcome! +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Ensure all tests pass +5. Submit a pull request ## ๐Ÿ“„ License @@ -97,4 +233,4 @@ MIT License - see [LICENSE](LICENSE) for details. --- -*This is an unofficial library and is not affiliated with The Guardian.* +*This is an unofficial library and is not affiliated with The Guardian.* \ No newline at end of file