Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Spond.API.Test/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,14 @@ private static async Task PrintGroups()

public static async Task<bool> Login(string emailPhone, string password)
{
if (await SpondClient.LoginWithEmail(emailPhone, password)) return true;
if (await SpondClient.LoginWithPhoneNumber(emailPhone, password)) return true;
static Task<string> OtpCallback(string maskedPhone)
{
Console.Write($"Enter the one-time password sent to {maskedPhone}: ");
return Task.FromResult(Console.ReadLine() ?? string.Empty);
}

if (await SpondClient.LoginWithEmail(emailPhone, password, OtpCallback)) return true;
if (await SpondClient.LoginWithPhoneNumber(emailPhone, password, OtpCallback)) return true;
return false;
}
}
89 changes: 89 additions & 0 deletions Spond.API/Models/CommonData.Core.V1.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using Spond.API.Extensions;
using Spond.API.Interfaces;

namespace Spond.API.Models;

/// <summary>
/// Implementation of ICommonData for the current Spond API (core/v1).
/// Provides API endpoints and URL construction for the active Spond API.
/// </summary>
internal class CommonData_Core_V1 : ICommonData
{
/// <inheritdoc/>
public string LoginTokenPropertyName => "loginToken";
/// <inheritdoc/>
public string BaseUrl => "https://api.spond.com/";
/// <inheritdoc/>
public string LoginUrl => "core/v1/login";
/// <inheritdoc/>
public string UserUrl => "core/v1/profile";
/// <inheritdoc/>
public string GroupsUrl => "core/v1/groups";

/// <summary>
/// Constructs the events URL with query parameters.
/// </summary>
/// <param name="parameters">List of query parameters.</param>
/// <returns>The complete events URL with query string.</returns>
private static string GetEventsUrl(List<string> parameters)
{
return $"core/v1/sponds{(parameters.Count > 0 ? "?" : string.Empty)}{string.Join('&', parameters)}";
}

/// <summary>
/// Builds the list of query parameters for event requests.
/// </summary>
/// <param name="minEndTime">The minimum end time for events.</param>
/// <param name="maxEndTime">The maximum end time for events.</param>
/// <param name="includeComments">Whether to include event comments.</param>
/// <param name="includeHidden">Whether to include hidden events.</param>
/// <param name="addProfileInfo">Whether to add profile information.</param>
/// <param name="scheduled">Whether to include scheduled events.</param>
/// <param name="order">The sort order for events.</param>
/// <param name="max">The maximum number of events to retrieve.</param>
/// <param name="groupId">Optional group ID filter.</param>
/// <param name="subGroupId">Optional subgroup ID filter.</param>
/// <returns>A list of formatted query parameter strings.</returns>
private static List<string> GetEventsParameters(DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max, string? groupId, string? subGroupId)
{
var parameters = new List<string>
{
$"minEndTimestamp={minEndTime.ToIso8601(true)}",
$"maxEndTimestamp={maxEndTime.ToIso8601(true)}",
$"max={max ?? (int)Math.Max(1, Math.Round((maxEndTime - minEndTime).TotalDays * 5))}",
$"order={order switch
{
Enums.Order.Ascending => "asc",
Enums.Order.Descending => "desc",
_ => "asc"
}}"
};

if (includeComments is not null) parameters.Add($"includeComments={includeComments.ToString()?.ToLower()}");
if (includeHidden is not null) parameters.Add($"includeHidden={includeHidden.ToString()?.ToLower()}");
if (addProfileInfo is not null) parameters.Add($"addProfileInfo={addProfileInfo.ToString()?.ToLower()}");
if (scheduled is not null) parameters.Add($"scheduled={scheduled.ToString()?.ToLower()}");
if (groupId is not null) parameters.Add($"groupId={groupId}");
if (subGroupId is not null) parameters.Add($"subGroupId={subGroupId}");

return parameters;
}

/// <inheritdoc/>
public string GetEventsUrl(DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max)
{
return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, null, null));
}

/// <inheritdoc/>
public string GetEventsUrl(string groupId, DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max)
{
return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, groupId, null));
}

/// <inheritdoc/>
public string GetEventsUrl(string groupId, string subGroupId, DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max)
{
return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, groupId, subGroupId));
}
}
114 changes: 102 additions & 12 deletions Spond.API/Services/SpondClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Spond.API.Models;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Newtonsoft.Json;
using static Spond.API.Enums;
using JsonDocument = System.Text.Json.JsonDocument;
Expand All @@ -22,59 +23,148 @@ public class SpondClient
/// <summary>
/// Initializes a new instance of the <see cref="SpondClient"/> class.
/// </summary>
/// <param name="commonData">Optional common data configuration. If null, defaults to CommonData_2_1.</param>
/// <param name="commonData">Optional common data configuration. If null, defaults to CommonData_Core_V1.</param>
/// <param name="logger">Optional logger for logging client operations.</param>
public SpondClient(ICommonData? commonData = null, ILogger<SpondClient>? logger = null)
{
_commonData = commonData ?? new CommonData_2_1();
_commonData = commonData ?? new CommonData_Core_V1();
_client = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer() }) { BaseAddress = new Uri(_commonData.BaseUrl) };
Comment on lines +26 to 31
_logger = logger;
}

/// <summary>
/// Authenticates with the Spond API using an email address and password.
/// When the account requires two-factor authentication, the <paramref name="otpCallback"/>
/// is invoked with the masked phone number that received the one-time code.
/// The callback must return the OTP entered by the user.
/// If <paramref name="otpCallback"/> is <c>null</c> and 2FA is required, the login fails.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <param name="otpCallback">
/// Optional async callback invoked when a one-time password is needed.
/// Receives the masked phone number (e.g. "****12") and must return the OTP code.
/// </param>
/// <returns>True if login was successful, false otherwise.</returns>
public async Task<bool> LoginWithEmail(string email, string password)
public async Task<bool> LoginWithEmail(string email, string password, Func<string, Task<string>>? otpCallback = null)
{
var loginPayload = new { email, password };
return await Login(loginPayload);
return await Login(loginPayload, otpCallback);
}

/// <summary>
/// Authenticates with the Spond API using a phone number and password.
/// When the account requires two-factor authentication, the <paramref name="otpCallback"/>
/// is invoked with the masked phone number that received the one-time code.
/// The callback must return the OTP entered by the user.
/// If <paramref name="otpCallback"/> is <c>null</c> and 2FA is required, the login fails.
/// </summary>
/// <param name="phoneNumber">The user's phone number.</param>
/// <param name="password">The user's password.</param>
/// <param name="otpCallback">
/// Optional async callback invoked when a one-time password is needed.
/// Receives the masked phone number (e.g. "****12") and must return the OTP code.
/// </param>
/// <returns>True if login was successful, false otherwise.</returns>
public async Task<bool> LoginWithPhoneNumber(string phoneNumber, string password)
public async Task<bool> LoginWithPhoneNumber(string phoneNumber, string password, Func<string, Task<string>>? otpCallback = null)
{
var loginPayload = new { phoneNumber, password };
return await Login(loginPayload);
return await Login(loginPayload, otpCallback);
}

/// <summary>
/// Internal method to handle the login process with different payload types.
/// Supports the two-factor authentication flow: if the API responds with a
/// temporary token and a masked phone number instead of a login token, the
/// <paramref name="otpCallback"/> is used to obtain the one-time password and
/// a second verification request is sent to complete authentication.
/// </summary>
/// <typeparam name="T">The type of the login payload (email or phone number).</typeparam>
/// <param name="loginPayload">The login credentials payload.</param>
/// <param name="otpCallback">
/// Optional async callback invoked when 2FA is required.
/// Receives the masked phone number and must return the OTP code.
/// </param>
/// <returns>True if login was successful, false otherwise.</returns>
private async Task<bool> Login<T>(T loginPayload)
private async Task<bool> Login<T>(T loginPayload, Func<string, Task<string>>? otpCallback)
{
var loginResp = await _client.PostAsJsonAsync(_commonData.LoginUrl, loginPayload);
if (!loginResp.IsSuccessStatusCode)
{
_logger?.LogError($"Error logging in: {loginResp.StatusCode} - {loginResp.Content.ReadAsStringAsync()}");
_logger?.LogError("Error logging in: {StatusCode}", loginResp.StatusCode);
return false;
}

var loginJson = await loginResp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(loginJson);
var loginToken = doc.RootElement.GetProperty(_commonData.LoginTokenPropertyName).GetString();
_client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginToken);
return true;
var root = doc.RootElement;

// Happy path: direct login token returned (no 2FA).
if (root.TryGetProperty(_commonData.LoginTokenPropertyName, out var tokenElement))
{
var loginToken = tokenElement.GetString();
if (string.IsNullOrEmpty(loginToken))
{
_logger?.LogError("Login response contained an empty login token.");
return false;
}
_client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginToken);
return true;
}

// 2FA path: API returned a temporary token and the masked destination phone number.
if (root.TryGetProperty("token", out var tempTokenElement) &&
root.TryGetProperty("phoneNumber", out var phoneElement))
{
var tempToken = tempTokenElement.GetString();
var maskedPhone = phoneElement.GetString() ?? string.Empty;

if (otpCallback is null)
{
_logger?.LogError(
"Login requires a one-time password sent to {Phone}. " +
"Provide an otpCallback to handle two-factor authentication.",
maskedPhone);
return false;
}

var otpCode = await otpCallback(maskedPhone);
if (string.IsNullOrWhiteSpace(otpCode))
{
_logger?.LogError("OTP callback returned an empty code.");
return false;
}

var otpPayload = new { code = otpCode, token = tempToken };
var otpResp = await _client.PostAsJsonAsync(_commonData.LoginUrl, otpPayload);
if (!otpResp.IsSuccessStatusCode)
{
_logger?.LogError("OTP verification failed: {StatusCode}", otpResp.StatusCode);
return false;
}

var otpJson = await otpResp.Content.ReadAsStringAsync();
using var otpDoc = JsonDocument.Parse(otpJson);
if (!otpDoc.RootElement.TryGetProperty(_commonData.LoginTokenPropertyName, out var finalTokenElement))
{
_logger?.LogError("OTP verification response did not contain a login token.");
return false;
}

var finalToken = finalTokenElement.GetString();
if (string.IsNullOrEmpty(finalToken))
{
_logger?.LogError("OTP verification response contained an empty login token.");
return false;
}
_client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", finalToken);
return true;
}

_logger?.LogError("Unexpected login response: {Response}", loginJson);
return false;
}

/// <summary>
Expand Down
Loading