Skip to content
2 changes: 2 additions & 0 deletions MSStore.API/Packaged/IStorePackagedAPI.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -32,5 +33,6 @@ public interface IStorePackagedAPI
Task<PackageRollout> UpdatePackageRolloutPercentageAsync(string productId, string submissionId, string? flightId, float percentage, CancellationToken ct = default);
Task<PackageRollout> HaltPackageRolloutAsync(string productId, string submissionId, string? flightId, CancellationToken ct = default);
Task<PackageRollout> FinalizePackageRolloutAsync(string productId, string submissionId, string? flightId, CancellationToken ct = default);
Task<PagedResponse<AppReview>> GetAppReviewsAsync(string productId, DateOnly? startDate = null, DateOnly? endDate = null, int? top = null, int? skip = null, string? filter = null, string? orderby = null, CancellationToken ct = default);
}
}
82 changes: 82 additions & 0 deletions MSStore.API/Packaged/Models/AppReview.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Text.Json.Serialization;

namespace MSStore.API.Packaged.Models
{
/// <summary>
/// A single customer review of an application, as returned by the Microsoft Store
/// analytics API (<c>/v1.0/my/analytics/reviews</c>).
/// </summary>
/// <remarks>
/// Every property is optional. The service omits fields entirely (rather than
/// returning them as null) whenever the data was not captured for a given review.
/// </remarks>
public class AppReview
{
/// <summary>
/// Gets or sets the unique identifier of the review.
/// </summary>
public string? Id { get; set; }

/// <summary>
/// Gets or sets the date the review was submitted. The service returns this as a
/// string in US format (for example <c>3/5/2021 12:48:33 PM</c>) rather than ISO-8601,
/// so it is kept as a string to round-trip exactly.
/// </summary>
public string? Date { get; set; }

public string? ApplicationId { get; set; }
public string? ApplicationName { get; set; }

/// <summary>
/// Gets or sets the ISO 3166 country code of the market the review was submitted in.
/// This is a country, not a language.
/// </summary>
public string? Market { get; set; }

public string? OsVersion { get; set; }
public string? DeviceType { get; set; }
public bool? IsRevised { get; set; }
public string? PackageVersion { get; set; }
public string? DeviceModel { get; set; }
public string? ProductFamily { get; set; }
public long? DeviceRAM { get; set; }
public string? DeviceScreenResolution { get; set; }
public double? DeviceStorageCapacity { get; set; }
public bool? IsTouchEnabled { get; set; }
public string? ReviewerName { get; set; }
public double? Rating { get; set; }
public string? ReviewTitle { get; set; }
public string? ReviewText { get; set; }
public long? HelpfulCount { get; set; }
public long? NotHelpfulCount { get; set; }
public string? ResponseDate { get; set; }
public string? ResponseText { get; set; }

/// <summary>
/// Gets or sets the review title translated into the requested language. This is
/// never returned by the Store, which offers no translation; it is populated by the
/// CLI when translation is requested, and omitted from output otherwise.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TranslatedReviewTitle { get; set; }

/// <summary>
/// Gets or sets the review text translated into the requested language. This is
/// never returned by the Store, which offers no translation; it is populated by the
/// CLI when translation is requested, and omitted from output otherwise.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TranslatedReviewText { get; set; }

/// <summary>
/// Gets or sets the language detected in the original review text. The Store returns
/// no language information, so this comes from the translation service and is only
/// present when translation was requested.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DetectedLanguage { get; set; }
}
}
72 changes: 72 additions & 0 deletions MSStore.API/Packaged/StorePackagedAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class StorePackagedAPI : IStorePackagedAPI, IDisposable
private static readonly CompositeFormat DevCenterUpdatePackageRolloutPercentageTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/updatepackagerolloutpercentage?percentage={4}");
private static readonly CompositeFormat DevCenterHaltPackageRolloutTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/haltpackagerollout");
private static readonly CompositeFormat DevCenterFinalizePackageRolloutTemplate = CompositeFormat.Parse("/v{0}/my/applications/{1}{2}/submissions/{3}/finalizepackagerollout");
private static readonly CompositeFormat DevCenterAnalyticsReviewsTemplate = CompositeFormat.Parse("/v{0}/my/analytics/reviews?applicationId={1}");

private SubmissionClient? _devCenterClient;

Expand Down Expand Up @@ -663,5 +664,76 @@ public async Task<PackageRollout> FinalizePackageRolloutAsync(string productId,
SourceGenerationContext.GetCustom().PackageRollout,
ct);
}

/// <summary>
/// The maximum number of reviews the analytics API accepts for the <c>top</c> parameter.
/// Larger values are rejected with <c>InvalidQueryParameters</c>.
/// </summary>
public const int MaxReviewsPerRequest = 10000;

public async Task<PagedResponse<AppReview>> GetAppReviewsAsync(string productId, DateOnly? startDate = null, DateOnly? endDate = null, int? top = null, int? skip = null, string? filter = null, string? orderby = null, CancellationToken ct = default)
{
// Arguments are validated before the client state so that a bad value is always
// reported as such, rather than depending on initialization order.
if (top is < 1)
{
throw new ArgumentOutOfRangeException(nameof(top), "The number of reviews to retrieve must be at least 1.");
}

if (top is > MaxReviewsPerRequest)
{
throw new ArgumentOutOfRangeException(nameof(top), $"The Microsoft Store analytics API accepts at most {MaxReviewsPerRequest} reviews per request.");
}

if (skip is < 0)
{
throw new ArgumentOutOfRangeException(nameof(skip), "The number of reviews to skip cannot be negative.");
}

AssertClientInitialized();

var url = new StringBuilder(string.Format(
CultureInfo.InvariantCulture,
DevCenterAnalyticsReviewsTemplate,
DevCenterVersion,
Uri.EscapeDataString(productId)));

if (startDate.HasValue)
{
url.Append(CultureInfo.InvariantCulture, $"&startDate={startDate.Value:yyyy-MM-dd}");
}

if (endDate.HasValue)
{
url.Append(CultureInfo.InvariantCulture, $"&endDate={endDate.Value:yyyy-MM-dd}");
}

if (top.HasValue)
{
url.Append(CultureInfo.InvariantCulture, $"&top={top.Value}");
}

if (skip.HasValue)
{
url.Append(CultureInfo.InvariantCulture, $"&skip={skip.Value}");
}

if (!string.IsNullOrEmpty(filter))
{
url.Append(CultureInfo.InvariantCulture, $"&filter={Uri.EscapeDataString(filter)}");
}

if (!string.IsNullOrEmpty(orderby))
{
url.Append(CultureInfo.InvariantCulture, $"&orderby={Uri.EscapeDataString(orderby)}");
}

return await _devCenterClient.InvokeAsync<PagedResponse<AppReview>>(
HttpMethod.Get,
url.ToString(),
null,
SourceGenerationContext.GetCustom().PagedResponseAppReview,
ct);
}
}
}
2 changes: 2 additions & 0 deletions MSStore.API/SourceGenerationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ namespace MSStore.API.Models
[JsonSerializable(typeof(PagedResponse<DevCenterFlight>))]
[JsonSerializable(typeof(DevCenterFlightSubmission))]
[JsonSerializable(typeof(DevCenterFlightSubmissionUpdate))]
[JsonSerializable(typeof(PagedResponse<AppReview>))]
[JsonSerializable(typeof(AppReview))]
public partial class SourceGenerationContext : JsonSerializerContext
{
private static SourceGenerationContext? _default;
Expand Down
Loading