diff --git a/.gitignore b/.gitignore index f06235c..bdb46ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules dist + +dotnet/**/bin +dotnet/**/obj diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3971fc7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Repository Purpose + +This repository contains a TypeScript implementation of the AliExpress SDK and its .NET port. It serves as a blueprint and reference for building AliExpress integrations in different ecosystems. + +# Development Guidelines + +- Ensure that every change builds and tests the .NET solution. +- Run `dotnet build` and `dotnet test` before committing. + diff --git a/AliExpressDotnetSdk.sln b/AliExpressDotnetSdk.sln new file mode 100644 index 0000000..4693177 --- /dev/null +++ b/AliExpressDotnetSdk.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet", "dotnet", "{1CA53CC0-F2D2-4468-82AB-2C4BEB1B9224}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliExpressSdk", "dotnet\AliExpressSdk\AliExpressSdk.csproj", "{BA27C6F7-3935-43A5-8EDB-F26F43ED0A2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliExpressSdk.Tests", "dotnet\AliExpressSdk.Tests\AliExpressSdk.Tests.csproj", "{5C6E9E68-3619-4C76-A976-6A6BB38BC4F2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BA27C6F7-3935-43A5-8EDB-F26F43ED0A2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA27C6F7-3935-43A5-8EDB-F26F43ED0A2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA27C6F7-3935-43A5-8EDB-F26F43ED0A2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA27C6F7-3935-43A5-8EDB-F26F43ED0A2C}.Release|Any CPU.Build.0 = Release|Any CPU + {5C6E9E68-3619-4C76-A976-6A6BB38BC4F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C6E9E68-3619-4C76-A976-6A6BB38BC4F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C6E9E68-3619-4C76-A976-6A6BB38BC4F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C6E9E68-3619-4C76-A976-6A6BB38BC4F2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BA27C6F7-3935-43A5-8EDB-F26F43ED0A2C} = {1CA53CC0-F2D2-4468-82AB-2C4BEB1B9224} + {5C6E9E68-3619-4C76-A976-6A6BB38BC4F2} = {1CA53CC0-F2D2-4468-82AB-2C4BEB1B9224} + EndGlobalSection +EndGlobal diff --git a/dotnet/AliExpressSdk.Tests/AliExpressSdk.Tests.csproj b/dotnet/AliExpressSdk.Tests/AliExpressSdk.Tests.csproj new file mode 100644 index 0000000..789b27a --- /dev/null +++ b/dotnet/AliExpressSdk.Tests/AliExpressSdk.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/dotnet/AliExpressSdk.Tests/UnitTest1.cs b/dotnet/AliExpressSdk.Tests/UnitTest1.cs new file mode 100644 index 0000000..98ec673 --- /dev/null +++ b/dotnet/AliExpressSdk.Tests/UnitTest1.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using AliExpressSdk.Clients; +using Xunit; + +namespace AliExpressSdk.Tests; + +public class SigningTests +{ + private class TestClient : AEBaseClient + { + public TestClient() : base("test", "secret", "session") { } + public string DoSign(IDictionary p) => Sign(p); + } + + [Fact] + public void Sign_ComputesExpectedHash() + { + var client = new TestClient(); + var parameters = new Dictionary + { + ["method"] = "/auth/token/create", + ["app_key"] = "test", + ["session"] = "session", + ["timestamp"] = "12345", + ["simplify"] = "true", + ["sign_method"] = "sha256" + }; + var sign = client.DoSign(parameters); + Assert.Equal("083482F9A0CE8559B567E46222AA1401B09BBDACC409D0BDA77A9A385A0BD31C", sign); + } +} diff --git a/dotnet/AliExpressSdk/AliExpressSdk.csproj b/dotnet/AliExpressSdk/AliExpressSdk.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/dotnet/AliExpressSdk/AliExpressSdk.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/dotnet/AliExpressSdk/Clients/AEBaseClient.cs b/dotnet/AliExpressSdk/Clients/AEBaseClient.cs new file mode 100644 index 0000000..3580a13 --- /dev/null +++ b/dotnet/AliExpressSdk/Clients/AEBaseClient.cs @@ -0,0 +1,135 @@ +using System.Linq; +using System.Globalization; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AliExpressSdk.Models; + +namespace AliExpressSdk.Clients; + +public class AEBaseClient +{ + private readonly HttpClient _httpClient; + public string AppKey { get; } + public string AppSecret { get; } + public string Session { get; } + + private const string TopApiUrl = "https://api-sg.aliexpress.com/sync"; + private const string OpApiUrl = "https://api-sg.aliexpress.com/rest"; + private const string SignMethod = "sha256"; + + public AEBaseClient(string appKey, string appSecret, string session, HttpClient? httpClient = null) + { + AppKey = appKey; + AppSecret = appSecret; + Session = session; + _httpClient = httpClient ?? new HttpClient(); + } + + protected string Sign(IDictionary parameters) + { + var p = new Dictionary(parameters); + var baseString = string.Empty; + if (p.TryGetValue("method", out var method) && method.Contains('/')) + { + baseString = method; + p.Remove("method"); + } + + foreach (var kv in p.Where(kv => kv.Value != null).OrderBy(kv => kv.Key)) + { + baseString += kv.Key + kv.Value; + } + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(AppSecret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(baseString)); + return string.Concat(hash.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); + } + + protected string Assemble(IDictionary parameters) + { + var p = new Dictionary(parameters); + var baseUrl = p["method"].Contains('/') ? $"{OpApiUrl}{p["method"]}" : TopApiUrl; + if (p["method"].Contains('/')) + { + p.Remove("method"); + } + + var query = string.Join("&", p + .Where(kv => kv.Value != null) + .OrderBy(kv => kv.Key) + .Select((kv, idx) => + { + var prefix = idx == 0 ? "?" : "&"; + return prefix + Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value); + })); + + return baseUrl + query; + } + + protected async Task> Call(IDictionary parameters) + { + var url = Assemble(parameters); + try + { + var response = await _httpClient.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + return new Result { Ok = false, Message = $"HTTP Error: {(int)response.StatusCode} {response.ReasonPhrase}" }; + } + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + if (root.TryGetProperty("error_response", out var error)) + { + return new Result + { + Ok = false, + Message = "Bad request", + ErrorResponse = error, + RequestId = error.GetProperty("request_id").GetString() + }; + } + return new Result { Ok = true, Data = root }; + } + catch (Exception ex) + { + return new Result { Ok = false, Message = ex.Message }; + } + } + + protected async Task> Execute(string method, IDictionary parameters) + { + var p = new Dictionary(parameters) + { + ["method"] = method, + ["session"] = Session, + ["app_key"] = AppKey, + ["simplify"] = "true", + ["sign_method"] = SignMethod, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) + }; + p["sign"] = Sign(p); + return await Call(p); + } + + public Task> CallApiDirectly(string method, IDictionary parameters) + { + if (string.IsNullOrWhiteSpace(method)) + { + return Task.FromResult(new Result { Ok = false, Message = "Method parameter is required" }); + } + var p = new Dictionary(parameters) + { + ["method"] = method, + ["session"] = Session, + ["app_key"] = AppKey, + ["simplify"] = "true", + ["sign_method"] = SignMethod, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) + }; + p["sign"] = Sign(p); + return Call(p); + } +} diff --git a/dotnet/AliExpressSdk/Clients/AESystemClient.cs b/dotnet/AliExpressSdk/Clients/AESystemClient.cs new file mode 100644 index 0000000..5565eda --- /dev/null +++ b/dotnet/AliExpressSdk/Clients/AESystemClient.cs @@ -0,0 +1,25 @@ +using System.Net.Http; +using System.Text.Json; +using AliExpressSdk.Models; + +namespace AliExpressSdk.Clients; + +public class AESystemClient : AEBaseClient +{ + public AESystemClient(string appKey, string appSecret, string session, HttpClient? httpClient = null) + : base(appKey, appSecret, session, httpClient) + { + } + + public Task> GenerateSecurityToken(IDictionary args) + => Execute("/auth/token/security/create", args); + + public Task> GenerateToken(IDictionary args) + => Execute("/auth/token/create", args); + + public Task> RefreshSecurityToken(IDictionary args) + => Execute("/auth/token/security/refresh", args); + + public Task> RefreshToken(IDictionary args) + => Execute("/auth/token/refresh", args); +} diff --git a/dotnet/AliExpressSdk/Clients/AffiliateClient.cs b/dotnet/AliExpressSdk/Clients/AffiliateClient.cs new file mode 100644 index 0000000..9ddb6c8 --- /dev/null +++ b/dotnet/AliExpressSdk/Clients/AffiliateClient.cs @@ -0,0 +1,49 @@ +using System.Net.Http; +using System.Text.Json; +using AliExpressSdk.Models; + +namespace AliExpressSdk.Clients; + +public class AffiliateClient : AESystemClient +{ + public AffiliateClient(string appKey, string appSecret, string session, HttpClient? httpClient = null) + : base(appKey, appSecret, session, httpClient) + { + } + + public Task> GenerateAffiliateLinks(IDictionary args) + => Execute("aliexpress.affiliate.link.generate", args); + + public Task> GetCategories(IDictionary args) + => Execute("aliexpress.affiliate.category.get", args); + + public Task> FeaturedPromoInfo(IDictionary args) + => Execute("aliexpress.affiliate.featuredpromo.get", args); + + public Task> FeaturedPromoProducts(IDictionary args) + => Execute("aliexpress.affiliate.featuredpromo.products.get", args); + + public Task> GetHotProductsDownload(IDictionary args) + => Execute("aliexpress.affiliate.hotproduct.download", args); + + public Task> GetHotProducts(IDictionary args) + => Execute("aliexpress.affiliate.hotproduct.query", args); + + public Task> OrderInfo(IDictionary args) + => Execute("aliexpress.affiliate.order.get", args); + + public Task> OrdersList(IDictionary args) + => Execute("aliexpress.affiliate.order.list", args); + + public Task> OrdersListByIndex(IDictionary args) + => Execute("aliexpress.affiliate.order.listbyindex", args); + + public Task> ProductDetails(IDictionary args) + => Execute("aliexpress.affiliate.productdetail.get", args); + + public Task> QueryProducts(IDictionary args) + => Execute("aliexpress.affiliate.product.query", args); + + public Task> SmartMatchProducts(IDictionary args) + => Execute("aliexpress.affiliate.product.smartmatch", args); +} diff --git a/dotnet/AliExpressSdk/Models/Result.cs b/dotnet/AliExpressSdk/Models/Result.cs new file mode 100644 index 0000000..5e8536d --- /dev/null +++ b/dotnet/AliExpressSdk/Models/Result.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace AliExpressSdk.Models; + +public class Result +{ + public bool Ok { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } + public JsonElement? ErrorResponse { get; set; } + public string? RequestId { get; set; } +}