diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d988da --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +project.lock.json +test/SegmentDotNet.Tests/bin/ +test/SegmentDotNet.Tests/obj/ +src/SegmentDotNet/obj/ +src/SegmentDotNet/bin/Debug/ +test/SegmentDotNet.Tests/.env +.vs/ diff --git a/Readme.md b/Readme.md index ae3eb9e..e345393 100644 --- a/Readme.md +++ b/Readme.md @@ -1,3 +1,13 @@ # SegmentDotNet -Segment integration for your .NET Core projects, relies on dependency injection. \ No newline at end of file +Segment integration for your .NET Core projects, relies on dependency injection. + +# Tests + +Copy test/SegmentDotNet.Tests/sample.env to test/SegmentDotNet.Tests/.env and fill out the `SEGMENT_WRITE_KEY` variable with your Segment write key. + +To run tests run `dotnet test` from the command line using test/SegmentDotNet.tests as your working directory. + +There would be more tests but [Segment returns 200 response codes](https://segment.com/docs/libraries/http/#selecting-integrations) regardless of invalid input, auth keys, or any other bad information other than malformed/too large JSON. I'll add more once Segment fixes this. + +Running tests will attempt to push events using the key defined as the environment variable `SEGMENT_WRITE_KEY`, you can check for results in the debugger on Segment for now. \ No newline at end of file diff --git a/SegmentDotNet.sln b/SegmentDotNet.sln new file mode 100644 index 0000000..1ce3e8b --- /dev/null +++ b/SegmentDotNet.sln @@ -0,0 +1,44 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25123.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5E6DE7BC-0749-4C63-8441-95A5756E5167}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EDA04816-70FA-4427-AC2D-D5FBA1B12951}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + global.json = global.json + LICENSE.txt = LICENSE.txt + Readme.md = Readme.md + EndProjectSection +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "SegmentDotNet", "src\SegmentDotNet\SegmentDotNet.xproj", "{AE25F674-BAAB-41B7-8FF5-36EAF7341ECC}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "SegmentDotNet.Tests", "test\SegmentDotNet.Tests\SegmentDotNet.Tests.xproj", "{D5950492-E758-4EB5-AE16-F5D4BCF1EC85}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{5BF05EAE-3AD2-40BF-9EE1-5E92C1F5A90B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AE25F674-BAAB-41B7-8FF5-36EAF7341ECC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE25F674-BAAB-41B7-8FF5-36EAF7341ECC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE25F674-BAAB-41B7-8FF5-36EAF7341ECC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE25F674-BAAB-41B7-8FF5-36EAF7341ECC}.Release|Any CPU.Build.0 = Release|Any CPU + {D5950492-E758-4EB5-AE16-F5D4BCF1EC85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5950492-E758-4EB5-AE16-F5D4BCF1EC85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5950492-E758-4EB5-AE16-F5D4BCF1EC85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5950492-E758-4EB5-AE16-F5D4BCF1EC85}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {AE25F674-BAAB-41B7-8FF5-36EAF7341ECC} = {5E6DE7BC-0749-4C63-8441-95A5756E5167} + {D5950492-E758-4EB5-AE16-F5D4BCF1EC85} = {5BF05EAE-3AD2-40BF-9EE1-5E92C1F5A90B} + EndGlobalSection +EndGlobal diff --git a/global.json b/global.json new file mode 100644 index 0000000..e793049 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "projects": [ "src", "test" ], + "sdk": { + "version": "1.0.0-preview2-003121" + } +} diff --git a/src/SegmentDotNet/Abstractions/IDateTime.cs b/src/SegmentDotNet/Abstractions/IDateTime.cs new file mode 100644 index 0000000..635766c --- /dev/null +++ b/src/SegmentDotNet/Abstractions/IDateTime.cs @@ -0,0 +1,9 @@ +namespace SegmentDotNet.Abstractions +{ + using System; + + public interface IDateTime + { + DateTime UtcNow { get; } + } +} diff --git a/src/SegmentDotNet/Abstractions/SystemDateTime.cs b/src/SegmentDotNet/Abstractions/SystemDateTime.cs new file mode 100644 index 0000000..35429a2 --- /dev/null +++ b/src/SegmentDotNet/Abstractions/SystemDateTime.cs @@ -0,0 +1,9 @@ +namespace SegmentDotNet.Abstractions +{ + using System; + + public class SystemDateTime : IDateTime + { + public DateTime UtcNow { get { return DateTime.UtcNow; } } + } +} diff --git a/src/SegmentDotNet/Client/Request/Abstract/AnonymousBase.cs b/src/SegmentDotNet/Client/Request/Abstract/AnonymousBase.cs new file mode 100644 index 0000000..633b67c --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Abstract/AnonymousBase.cs @@ -0,0 +1,17 @@ +namespace SegmentDotNet.Client.Request.Abstract +{ + using Abstractions; + using Newtonsoft.Json; + using Populators.Contexts; + using Populators.Integrations; + + public abstract class AnonymousBase : UserTimestampBase + { + public AnonymousBase(Context context, IDateTime datetime, Integrations integrations) : base(context, datetime, integrations) + { + } + + [JsonProperty("anonymousId")] + public string AnonymousId { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Abstract/Base.cs b/src/SegmentDotNet/Client/Request/Abstract/Base.cs new file mode 100644 index 0000000..eb72bf9 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Abstract/Base.cs @@ -0,0 +1,26 @@ +namespace SegmentDotNet.Client.Request.Abstract +{ + using Interfaces; + using Newtonsoft.Json; + using Populators.Contexts; + using Populators.Integrations; + + public abstract class Base : ISegmentRequest + { + public Base( + Context context, + Integrations integrations) + { + this.Context = context; + this.Integrations = integrations; + } + + [JsonProperty("context")] + public Context Context { get; set; } + + [JsonProperty("integrations")] + public Integrations Integrations { get; set; } + + public abstract string Endpoint { get; } + } +} \ No newline at end of file diff --git a/src/SegmentDotNet/Client/Request/Abstract/TraitsBase.cs b/src/SegmentDotNet/Client/Request/Abstract/TraitsBase.cs new file mode 100644 index 0000000..52bcc1b --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Abstract/TraitsBase.cs @@ -0,0 +1,19 @@ +namespace SegmentDotNet.Client.Request.Abstract +{ + using Abstractions; + using Newtonsoft.Json; + using Populators.Traits; + using Populators.Contexts; + using Populators.Integrations; + + public abstract class TraitsBase : AnonymousBase + { + public TraitsBase(Context context, IDateTime datetime, Integrations integrations, Traits traits) : base(context, datetime, integrations) + { + this.Traits = traits; + } + + [JsonProperty("traits")] + public Traits Traits { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Abstract/UserTimestampBase.cs b/src/SegmentDotNet/Client/Request/Abstract/UserTimestampBase.cs new file mode 100644 index 0000000..a4fff93 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Abstract/UserTimestampBase.cs @@ -0,0 +1,32 @@ +namespace SegmentDotNet.Client.Request.Abstract +{ + using Abstractions; + using Newtonsoft.Json; + using System; + using Populators.Contexts; + using Populators.Integrations; + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public abstract class UserTimestampBase : Base + { + public UserTimestampBase( + Context context, + IDateTime datetime, + Integrations integrations) + : base(context, integrations) + { + this.DateTime = datetime; + } + + protected IDateTime DateTime { get; set; } + + [JsonProperty("type")] + public abstract string Type { get; } + + [JsonProperty("userId")] + public string UserId { get; set; } + + [JsonProperty("timestamp")] + public DateTime Timestamp { get { return this.DateTime.UtcNow; } } + } +} diff --git a/src/SegmentDotNet/Client/Request/Alias.cs b/src/SegmentDotNet/Client/Request/Alias.cs new file mode 100644 index 0000000..03fc3d0 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Alias.cs @@ -0,0 +1,25 @@ + +namespace SegmentDotNet.Client.Request +{ + using System; + using Abstract; + using Abstractions; + using Newtonsoft.Json; + using Populators.Contexts; + using Populators.Integrations; + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class Alias : UserTimestampBase + { + public Alias(Context context, IDateTime datetime, Integrations integrations) : base(context, datetime, integrations) + { + } + + public override string Type { get { return "alias"; } } + + public override string Endpoint { get { return "alias"; } } + + [JsonProperty("previousId")] + public string PreviousId { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Batch.cs b/src/SegmentDotNet/Client/Request/Batch.cs new file mode 100644 index 0000000..f213462 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Batch.cs @@ -0,0 +1,22 @@ +namespace SegmentDotNet.Client.Request +{ + using Abstract; + using Newtonsoft.Json; + using System.Collections.Generic; + using Populators.Contexts; + using Populators.Integrations; + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class Batch : Base + { + public Batch(Context context, Integrations integrations) : base(context, integrations) + { + this.Items = new List(); + } + + public override string Endpoint { get { return "batch"; } } + + [JsonProperty("batch")] + public List Items { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Group.cs b/src/SegmentDotNet/Client/Request/Group.cs new file mode 100644 index 0000000..6ec399b --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Group.cs @@ -0,0 +1,26 @@ +using System; + +namespace SegmentDotNet.Client.Request +{ + using Abstract; + using Abstractions; + using Newtonsoft.Json; + using Populators.Traits; + using Populators.Contexts; + using Populators.Integrations; + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class Group : TraitsBase + { + public Group(Context context, IDateTime datetime, Integrations integrations, Traits traits) : base(context, datetime, integrations, traits) + { + } + + public override string Type { get { return "group"; } } + + public override string Endpoint { get { return "group"; } } + + [JsonProperty("groupId")] + public string GroupId { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Identify.cs b/src/SegmentDotNet/Client/Request/Identify.cs new file mode 100644 index 0000000..58bcc98 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Identify.cs @@ -0,0 +1,21 @@ +namespace SegmentDotNet.Client.Request +{ + using Abstract; + using Abstractions; + using Newtonsoft.Json; + using Populators.Traits; + using Populators.Contexts; + using Populators.Integrations; + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class Identify : TraitsBase + { + public Identify(Context context, IDateTime datetime, Integrations integrations, Traits traits) : base(context, datetime, integrations, traits) + { + } + + public override string Type { get { return "identify"; } } + + public override string Endpoint { get { return "identify"; } } + } +} diff --git a/src/SegmentDotNet/Client/Request/Interfaces/ISegmentRequest.cs b/src/SegmentDotNet/Client/Request/Interfaces/ISegmentRequest.cs new file mode 100644 index 0000000..b1e3d3e --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Interfaces/ISegmentRequest.cs @@ -0,0 +1,7 @@ +namespace SegmentDotNet.Client.Request.Interfaces +{ + public interface ISegmentRequest + { + string Endpoint { get; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Page.cs b/src/SegmentDotNet/Client/Request/Page.cs new file mode 100644 index 0000000..0f14f31 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Page.cs @@ -0,0 +1,23 @@ +namespace SegmentDotNet.Client.Request +{ + using Abstract; + using Abstractions; + using Newtonsoft.Json; + using Populators.Contexts; + using Populators.Integrations; + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class Page : AnonymousBase + { + public Page(Context context, IDateTime datetime, Integrations integrations) : base(context, datetime, integrations) + { + } + + public override string Type { get { return "page"; } } + + public override string Endpoint { get { return "page"; } } + + [JsonProperty("name")] + public string Name { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Screen.cs b/src/SegmentDotNet/Client/Request/Screen.cs new file mode 100644 index 0000000..0df7f65 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Screen.cs @@ -0,0 +1,23 @@ +namespace SegmentDotNet.Client.Request +{ + using Abstract; + using Abstractions; + using Newtonsoft.Json; + using Populators.Contexts; + using Populators.Integrations; + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class Screen : AnonymousBase + { + public Screen(Context context, IDateTime datetime, Integrations integrations) : base(context, datetime, integrations) + { + } + + public override string Type { get { return "screen"; } } + + public override string Endpoint { get { return "screen"; } } + + [JsonProperty("name")] + public string Name { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/Request/Track.cs b/src/SegmentDotNet/Client/Request/Track.cs new file mode 100644 index 0000000..de51d55 --- /dev/null +++ b/src/SegmentDotNet/Client/Request/Track.cs @@ -0,0 +1,27 @@ +namespace SegmentDotNet.Client.Request +{ + using Abstract; + using Abstractions; + using Newtonsoft.Json; + using Populators.Contexts; + using Populators.Integrations; + using Populators.Properties; + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class Track : AnonymousBase + { + public Track(Context context, IDateTime datetime, Integrations integrations, Properties properties) : base(context, datetime, integrations) + { + this.Properties = properties; + } + + public override string Type { get { return "track"; } } + + public override string Endpoint { get { return "track"; } } + + [JsonProperty("event")] + public string Event { get; set; } + + [JsonProperty("properties")] + public Properties Properties { get; set; } + } +} diff --git a/src/SegmentDotNet/Client/SegmentClient.cs b/src/SegmentDotNet/Client/SegmentClient.cs new file mode 100644 index 0000000..ef6f0ad --- /dev/null +++ b/src/SegmentDotNet/Client/SegmentClient.cs @@ -0,0 +1,86 @@ +namespace SegmentDotNet.Client +{ + using Configuration; + using Microsoft.Extensions.Options; + using Newtonsoft.Json; + using Request; + using Request.Abstract; + using System; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Text; + using System.Threading.Tasks; + + public class SegmentClient + { + public SegmentClient(IOptions configuration) + { + this.Configuration = configuration.Value; + } + + protected const string URL = "https://api.segment.io/v1/"; + + protected SegmentConfiguration Configuration { get; set; } + + public async Task Alias(Alias alias) + { + await this.Post(alias); + } + + public async Task Batch(Batch batch) + { + await this.Post(batch); + } + + public async Task Identify(Identify identify) + { + await this.Post(identify); + } + + public async Task Group(Group group) + { + await this.Post(group); + } + + public async Task Screen(Screen screen) + { + await this.Post(screen); + } + + public async Task Page(Page page) + { + await this.Post(page); + } + + public async Task Track(Track track) + { + await this.Post(track); + } + + public async Task Post(Base baseClass) + { + var json = this.Serialize(baseClass); + using (var client = new HttpClient()) + { + client.DefaultRequestHeaders.Clear(); + client.DefaultRequestHeaders.Add("Authorization", $"Basic {this.GetWriteKey()}"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + var response = await client.PostAsync($"{URL}{baseClass.Endpoint}", new StringContent(json, Encoding.UTF8, "application/json")); + if (!response.IsSuccessStatusCode) + { + throw new Exception(response.ReasonPhrase); + } + } + } + + public string Serialize(object baseClass) + { + return JsonConvert.SerializeObject(baseClass); + } + + public string GetWriteKey() + { + return Convert.ToBase64String(Encoding.UTF8.GetBytes(this.Configuration.WriteKey + ":")); + } + } +} diff --git a/src/SegmentDotNet/Configuration/SegmentConfiguration.cs b/src/SegmentDotNet/Configuration/SegmentConfiguration.cs new file mode 100644 index 0000000..b54fb6a --- /dev/null +++ b/src/SegmentDotNet/Configuration/SegmentConfiguration.cs @@ -0,0 +1,7 @@ +namespace SegmentDotNet.Configuration +{ + public class SegmentConfiguration + { + public virtual string WriteKey { get; set; } + } +} diff --git a/src/SegmentDotNet/Json/ForcedObjectResolver.cs b/src/SegmentDotNet/Json/ForcedObjectResolver.cs new file mode 100644 index 0000000..b75eeae --- /dev/null +++ b/src/SegmentDotNet/Json/ForcedObjectResolver.cs @@ -0,0 +1,16 @@ +namespace SegmentDotNet.Json +{ + using System; + using Newtonsoft.Json.Serialization; + + internal class ForcedObjectResolver : DefaultContractResolver + { + public override JsonContract ResolveContract(Type type) + { + // We're going to null the converter to force it to serialize this as a plain object. + var contract = base.ResolveContract(type); + contract.Converter = null; + return contract; + } + } +} diff --git a/src/SegmentDotNet/Json/ForcedObjectSerializer.cs b/src/SegmentDotNet/Json/ForcedObjectSerializer.cs new file mode 100644 index 0000000..6ad518b --- /dev/null +++ b/src/SegmentDotNet/Json/ForcedObjectSerializer.cs @@ -0,0 +1,13 @@ +namespace SegmentDotNet.Json +{ + using Newtonsoft.Json; + + internal class ForcedObjectSerializer : JsonSerializer + { + public ForcedObjectSerializer() + : base() + { + this.ContractResolver = new ForcedObjectResolver(); + } + } +} diff --git a/src/SegmentDotNet/Populators/Contexts/Context.cs b/src/SegmentDotNet/Populators/Contexts/Context.cs new file mode 100644 index 0000000..14231e5 --- /dev/null +++ b/src/SegmentDotNet/Populators/Contexts/Context.cs @@ -0,0 +1,20 @@ +namespace SegmentDotNet.Populators.Contexts +{ + using System; + using System.Collections.Generic; + + public class Context : Populator + { + public Context(IEnumerable contexts) + { + this.Contexts = new List(contexts); + } + + protected List Contexts { get; set; } + + public override void Prepare() + { + this.Contexts.ForEach(c => c.UpdatePopulator(this.Properties)); + } + } +} diff --git a/src/SegmentDotNet/Populators/Contexts/GoogleAnalyitics.cs b/src/SegmentDotNet/Populators/Contexts/GoogleAnalyitics.cs new file mode 100644 index 0000000..1f9a71e --- /dev/null +++ b/src/SegmentDotNet/Populators/Contexts/GoogleAnalyitics.cs @@ -0,0 +1,48 @@ +namespace SegmentDotNet.Populators.Contexts +{ + using Microsoft.AspNetCore.Http; + using Newtonsoft.Json; + using System.Linq; + using System.Collections.Generic; + + public class GoogleAnalyitics : IContextUpdater + { + public class GoogleAnalyiticsPayload + { + [JsonProperty("clientId")] + public string ClientId { get; set; } + } + + public GoogleAnalyitics(IHttpContextAccessor httpContextAccessor) + { + this.HttpContextAccessor = httpContextAccessor; + this.Payload = this.GeneratePayload(); + } + + private GoogleAnalyiticsPayload GeneratePayload() + { + return new GoogleAnalyiticsPayload + { + ClientId = this.GetGoogleAnalyticsCookie() + }; + } + + public GoogleAnalyiticsPayload Payload { get; set; } + + protected IHttpContextAccessor HttpContextAccessor { get; set; } + + private string GetGoogleAnalyticsCookie() + { + var cookie = this.HttpContextAccessor.HttpContext.Request.Cookies["_ga"]; + return cookie == null ? null : string.Join(".", cookie.Split('.').Reverse().Take(2).Reverse()); + } + + public void UpdatePopulator(IDictionary properties) + { + if (this.Payload.ClientId != null) + { + properties.Add("Google Analyitics", this.Payload); + } + } + } +} diff --git a/src/SegmentDotNet/Populators/Contexts/IContextUpdater.cs b/src/SegmentDotNet/Populators/Contexts/IContextUpdater.cs new file mode 100644 index 0000000..e77edd2 --- /dev/null +++ b/src/SegmentDotNet/Populators/Contexts/IContextUpdater.cs @@ -0,0 +1,6 @@ +namespace SegmentDotNet.Populators.Contexts +{ + public interface IContextUpdater : IPopulatorUpdater + { + } +} diff --git a/src/SegmentDotNet/Populators/Contexts/Library.cs b/src/SegmentDotNet/Populators/Contexts/Library.cs new file mode 100644 index 0000000..8cc0fdf --- /dev/null +++ b/src/SegmentDotNet/Populators/Contexts/Library.cs @@ -0,0 +1,15 @@ +namespace SegmentDotNet.Populators.Contexts +{ + using System.Collections.Generic; + using System.Diagnostics; + using System.Reflection; + + public class Library : IContextUpdater + { + public void UpdatePopulator(IDictionary properties) + { + properties.Add("library.name", "SegmentDotNet"); + properties.Add("library.version", FileVersionInfo.GetVersionInfo(typeof(Library).GetTypeInfo().Assembly.Location).ProductVersion); + } + } +} diff --git a/src/SegmentDotNet/Populators/Contexts/Request.cs b/src/SegmentDotNet/Populators/Contexts/Request.cs new file mode 100644 index 0000000..5a15888 --- /dev/null +++ b/src/SegmentDotNet/Populators/Contexts/Request.cs @@ -0,0 +1,34 @@ +namespace SegmentDotNet.Populators.Contexts +{ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.Features; + using System.Collections.Generic; + + public class Request : IContextUpdater + { + public Request(IHttpContextAccessor httpContextAccessor) + { + this.HttpContextAccessor = httpContextAccessor; + } + + protected IHttpContextAccessor HttpContextAccessor { get; set; } + + public void UpdatePopulator(IDictionary properties) + { + var context = this.HttpContextAccessor.HttpContext; + properties.Add("ip", context.Features.Get().RemoteIpAddress.ToString()); + properties.Add("page.path", context.Request.Path.ToString()); + if (context.Request.Headers.ContainsKey("Referer")) + { + properties.Add("page.referrer", context.Request.Headers["Referer"].ToString()); + } + + if (context.Request.Headers.ContainsKey("User-Agent")) + { + properties.Add("userAgent", context.Request.Headers["User-Agent"].ToString()); + } + + properties.Add("url", $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"); + } + } +} diff --git a/src/SegmentDotNet/Populators/IPopulatorUpdater.cs b/src/SegmentDotNet/Populators/IPopulatorUpdater.cs new file mode 100644 index 0000000..2138dd6 --- /dev/null +++ b/src/SegmentDotNet/Populators/IPopulatorUpdater.cs @@ -0,0 +1,9 @@ +namespace SegmentDotNet.Populators +{ + using System.Collections.Generic; + + public interface IPopulatorUpdater + { + void UpdatePopulator(IDictionary properties); + } +} diff --git a/src/SegmentDotNet/Populators/Integrations/Integrations.cs b/src/SegmentDotNet/Populators/Integrations/Integrations.cs new file mode 100644 index 0000000..46108e5 --- /dev/null +++ b/src/SegmentDotNet/Populators/Integrations/Integrations.cs @@ -0,0 +1,11 @@ +using System; + +namespace SegmentDotNet.Populators.Integrations +{ + public class Integrations : Populator + { + public override void Prepare() + { + } + } +} diff --git a/src/SegmentDotNet/Populators/Populator.cs b/src/SegmentDotNet/Populators/Populator.cs new file mode 100644 index 0000000..1e68b87 --- /dev/null +++ b/src/SegmentDotNet/Populators/Populator.cs @@ -0,0 +1,9 @@ +namespace SegmentDotNet.Populators +{ + using PropertyContainers.Abstract; + + public abstract class Populator : PropertyContainerBase + { + public abstract void Prepare(); + } +} diff --git a/src/SegmentDotNet/Populators/Properties/Properties.cs b/src/SegmentDotNet/Populators/Properties/Properties.cs new file mode 100644 index 0000000..58dccc3 --- /dev/null +++ b/src/SegmentDotNet/Populators/Properties/Properties.cs @@ -0,0 +1,11 @@ +using System; + +namespace SegmentDotNet.Populators.Properties +{ + public class Properties : Populator + { + public override void Prepare() + { + } + } +} diff --git a/src/SegmentDotNet/Populators/Traits/Traits.cs b/src/SegmentDotNet/Populators/Traits/Traits.cs new file mode 100644 index 0000000..012918f --- /dev/null +++ b/src/SegmentDotNet/Populators/Traits/Traits.cs @@ -0,0 +1,11 @@ +using System; + +namespace SegmentDotNet.Populators.Traits +{ + public class Traits : Populator + { + public override void Prepare() + { + } + } +} diff --git a/src/SegmentDotNet/Properties/AssemblyInfo.cs b/src/SegmentDotNet/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b76ca2b --- /dev/null +++ b/src/SegmentDotNet/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("SegmentDotNet")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("ae25f674-baab-41b7-8ff5-36eaf7341ecc")] diff --git a/src/SegmentDotNet/PropertyContainers/Abstract/PropertyContainerBase.cs b/src/SegmentDotNet/PropertyContainers/Abstract/PropertyContainerBase.cs new file mode 100644 index 0000000..7cb8558 --- /dev/null +++ b/src/SegmentDotNet/PropertyContainers/Abstract/PropertyContainerBase.cs @@ -0,0 +1,18 @@ +namespace SegmentDotNet.PropertyContainers.Abstract +{ + using Interfaces; + using Newtonsoft.Json; + using Serializers; + using System.Collections.Generic; + + [JsonConverter(typeof(PropertyContainerBaseSerializer))] + public abstract class PropertyContainerBase : IPropertyContainer + { + public PropertyContainerBase() + { + this.Properties = new Dictionary(); + } + + public Dictionary Properties { get; set; } + } +} diff --git a/src/SegmentDotNet/PropertyContainers/Interfaces/IPropertyContainer.cs b/src/SegmentDotNet/PropertyContainers/Interfaces/IPropertyContainer.cs new file mode 100644 index 0000000..b4f638a --- /dev/null +++ b/src/SegmentDotNet/PropertyContainers/Interfaces/IPropertyContainer.cs @@ -0,0 +1,11 @@ +namespace SegmentDotNet.PropertyContainers.Interfaces +{ + using Newtonsoft.Json; + using System.Collections.Generic; + + public interface IPropertyContainer + { + [JsonIgnore] + Dictionary Properties { get; set; } + } +} diff --git a/src/SegmentDotNet/PropertyContainers/Serializers/PropertyContainerBaseSerializer.cs b/src/SegmentDotNet/PropertyContainers/Serializers/PropertyContainerBaseSerializer.cs new file mode 100644 index 0000000..c225835 --- /dev/null +++ b/src/SegmentDotNet/PropertyContainers/Serializers/PropertyContainerBaseSerializer.cs @@ -0,0 +1,38 @@ +namespace SegmentDotNet.PropertyContainers.Serializers +{ + using Interfaces; + using Json; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + using System; + using System.Reflection; + + public class PropertyContainerBaseSerializer : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return typeof(IPropertyContainer).GetTypeInfo().IsAssignableFrom(objectType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var propertyContainer = value as IPropertyContainer; + var valueToken = JToken.FromObject(value, new ForcedObjectSerializer()); + if (propertyContainer.Properties != null) + { + var propertiesToken = JToken.FromObject(propertyContainer.Properties); + foreach (var property in propertiesToken.Children()) + { + valueToken[property.Name] = property.Value; + } + } + + valueToken.WriteTo(writer); + } + } +} diff --git a/src/SegmentDotNet/SegmentDotNet.xproj b/src/SegmentDotNet/SegmentDotNet.xproj new file mode 100644 index 0000000..2bf53ce --- /dev/null +++ b/src/SegmentDotNet/SegmentDotNet.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + ae25f674-baab-41b7-8ff5-36eaf7341ecc + SegmentDotNet + .\obj + .\bin\ + v4.6 + + + + 2.0 + + + diff --git a/src/SegmentDotNet/project.json b/src/SegmentDotNet/project.json new file mode 100644 index 0000000..931715a --- /dev/null +++ b/src/SegmentDotNet/project.json @@ -0,0 +1,16 @@ +{ + "version": "0.1.0-*", + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "1.0.0", + "Microsoft.Extensions.Options": "1.0.0", + "NETStandard.Library": "1.6.0", + "Newtonsoft.Json": "9.0.1", + "System.Diagnostics.FileVersionInfo": "4.0.0", + "System.Dynamic.Runtime": "4.0.11" + }, + "frameworks": { + "netstandard1.5": { + "imports": "dnxcore50" + } + } +} diff --git a/test/SegmentDotNet.Tests/Client/SegmentClientTests.cs b/test/SegmentDotNet.Tests/Client/SegmentClientTests.cs new file mode 100644 index 0000000..98bc136 --- /dev/null +++ b/test/SegmentDotNet.Tests/Client/SegmentClientTests.cs @@ -0,0 +1,114 @@ +namespace SegmentDotNet.Tests.Client +{ + using Newtonsoft.Json; + using SegmentDotNet.Client.Request; + using System; + using System.IO; + using System.Threading.Tasks; + using Xunit; + + public class SegmentClientTests : TestBase, IDisposable + { + public SegmentClientTests() + { + // Tooling for this in the future would be amazing. + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SEGMENT_WRITE_KEY"))) + { + var env = File.ReadAllText(".env"); + var lines = env.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None); + foreach (var line in lines) + { + var segments = line.Split('='); + if (segments[0] == "SEGMENT_WRITE_KEY") + { + Environment.SetEnvironmentVariable("SEGMENT_WRITE_KEY", segments[1]); + } + } + } + } + + [Fact] + public void Should_Serialize_Timestamp() + { + var json = this.SetupClient().Serialize(new Alias(null, this.GetDateTimeMock(), null)); + var jsonObject = JsonConvert.DeserializeAnonymousType(json, new { timestamp = "" }); + Assert.Equal("2016-06-12T05:20:50.523Z", jsonObject.timestamp); + } + + [Fact] + public async Task Can_Identify() + { + var identify = new Identify(null, this.GetDateTimeMock(), null, null); + identify.UserId = "1234"; + await this.SetupClient().Identify(identify); + } + + [Fact] + public async Task Can_Group() + { + var group = new Group(null, this.GetDateTimeMock(), null, null); + group.GroupId = "G1234"; + group.UserId = "1234"; + await this.SetupClient().Group(group); + } + + [Fact] + public async Task Can_Track() + { + var track = new Track(null, this.GetDateTimeMock(), null, null); + track.UserId = "1234"; + track.Event = "Can_Track Test"; + await this.SetupClient().Track(track); + } + + [Fact] + public async Task Can_Page() + { + var page = new Page(null, this.GetDateTimeMock(), null); + page.UserId = "1234"; + page.Name = "Can_Page Test"; + await this.SetupClient().Page(page); + } + + [Fact] + public async Task Can_Screen() + { + var screen = new Screen(null, this.GetDateTimeMock(), null); + screen.UserId = "1234"; + screen.Name = "Can_Screen Test"; + await this.SetupClient().Screen(screen); + } + + [Fact] + public async Task Can_Alias() + { + var alias = new Alias(null, this.GetDateTimeMock(), null); + alias.PreviousId = "P1234"; + alias.UserId = "1234"; + await this.SetupClient().Alias(alias); + } + + [Fact] + public async Task Can_Batch() + { + var identify = new Identify(null, this.GetDateTimeMock(), null, null); + identify.UserId = "B1234"; + var batch = new Batch(null, null); + batch.Items.Add(identify); + await this.SetupClient().Batch(batch); + } + + [Fact] + public async Task Should_Error_On_Large_Json() + { + var page = new Page(null, this.GetDateTimeMock(), null); + page.UserId = "1234"; + page.Name = new string('a', 102400); + await Assert.ThrowsAsync(async () => await this.SetupClient().Page(page)); + } + + public void Dispose() + { + } + } +} diff --git a/test/SegmentDotNet.Tests/Populators/Contexts/GoogleAnalyiticsTests.cs b/test/SegmentDotNet.Tests/Populators/Contexts/GoogleAnalyiticsTests.cs new file mode 100644 index 0000000..6ebb900 --- /dev/null +++ b/test/SegmentDotNet.Tests/Populators/Contexts/GoogleAnalyiticsTests.cs @@ -0,0 +1,49 @@ +namespace SegmentDotNet.Tests.Populators.Contexts +{ + using Microsoft.AspNetCore.Http; + using Moq; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + using SegmentDotNet.Populators; + using SegmentDotNet.Populators.Contexts; + using System.Collections.Generic; + using Xunit; + + public class GoogleAnalyiticsTest : TestBase + { + [Fact] + public void Parses_Google_Analyitics_Cookie() + { + var googleAnalyitics = this.SetupGoogleAnalyitics(); + Assert.Equal("1033501218.1368477899", googleAnalyitics.Payload.ClientId); + } + + [Fact] + public void Serialize_With_Extensions() + { + var context = new Context(new List() { this.SetupGoogleAnalyitics() }); + context.Prepare(); + var json = JObject.Parse(this.SetupClient().Serialize(context)); + Assert.Equal("{\"Google Analyitics\":{\"clientId\":\"1033501218.1368477899\"}}", json.ToString(Formatting.None)); + } + + [Fact] + public void Handles_No_Cookie() + { + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.Setup(h => h.HttpContext.Request.Cookies["_ga"]).Returns(() => null); + var context = new Context(new List() { new GoogleAnalyitics(httpContextAccessorMock.Object) }); + context.Prepare(); + var json = JObject.Parse(this.SetupClient().Serialize(context)); + Assert.Equal("{}", json.ToString(Formatting.None)); + } + + protected GoogleAnalyitics SetupGoogleAnalyitics() + { + var gaCookie = "GA1.2.1033501218.1368477899"; + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.Setup(h => h.HttpContext.Request.Cookies["_ga"]).Returns(gaCookie); + return new GoogleAnalyitics(httpContextAccessorMock.Object); + } + } +} diff --git a/test/SegmentDotNet.Tests/Populators/Contexts/LibraryTests.cs b/test/SegmentDotNet.Tests/Populators/Contexts/LibraryTests.cs new file mode 100644 index 0000000..081144d --- /dev/null +++ b/test/SegmentDotNet.Tests/Populators/Contexts/LibraryTests.cs @@ -0,0 +1,22 @@ +namespace SegmentDotNet.Tests.Populators.Contexts +{ + using SegmentDotNet.Populators.Contexts; + using System.Collections.Generic; + using System.Diagnostics; + using System.Reflection; + using Xunit; + + public class LibraryTests + { + [Fact] + public void PopulatesDictionary() + { + var library = new Library(); + var dictionary = new Dictionary(); + library.UpdatePopulator(dictionary); + var version = FileVersionInfo.GetVersionInfo(typeof(Library).GetTypeInfo().Assembly.Location).ProductVersion; + Assert.Equal("SegmentDotNet", dictionary["library.name"]); + Assert.Equal(version, dictionary["library.version"]); + } + } +} diff --git a/test/SegmentDotNet.Tests/Populators/Contexts/RequestTests.cs b/test/SegmentDotNet.Tests/Populators/Contexts/RequestTests.cs new file mode 100644 index 0000000..7df7c49 --- /dev/null +++ b/test/SegmentDotNet.Tests/Populators/Contexts/RequestTests.cs @@ -0,0 +1,62 @@ +namespace SegmentDotNet.Tests.Populators.Contexts +{ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.Features; + using Moq; + using SegmentDotNet.Populators.Contexts; + using System.Collections.Generic; + using System.Net; + using Xunit; + + public class RequestTests + { + [Fact] + public void Updates_Dictionary() + { + var dictionary = this.UpdateDictionary(this.SetupMock().Object); + Assert.Equal("127.0.0.1", dictionary["ip"]); + Assert.Equal("/test/url.txt", dictionary["page.path"]); + Assert.Equal("http://segment.com", dictionary["page.referrer"]); + Assert.Equal("Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", dictionary["userAgent"]); + Assert.Equal("http://segment.com/test/url.txt", dictionary["url"]); + } + + [Fact] + public void Handles_Missing_Referer() + { + var dictionary = this.UpdateDictionary(this.SetupMock(addReferer: false).Object); + Assert.False(dictionary.ContainsKey("page.referrer")); + } + + [Fact] + public void Handles_Missing_User_Agent() + { + var dictionary = this.UpdateDictionary(this.SetupMock(addUserAgent: false).Object); + Assert.False(dictionary.ContainsKey("userAgent")); + } + + protected IDictionary UpdateDictionary(IHttpContextAccessor mock) + { + var request = new Request(mock); + var dictionary = new Dictionary(); + request.UpdatePopulator(dictionary); + return dictionary; + } + + protected Mock SetupMock(bool addReferer = true, bool addUserAgent = true) + { + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.HttpContext.Features.Get().RemoteIpAddress).Returns(IPAddress.Parse("127.0.0.1")); + contextAccessorMock.Setup(c => c.HttpContext.Request.Path).Returns("/test/url.txt"); + contextAccessorMock.Setup(c => c.HttpContext.Request.Scheme).Returns("http"); + contextAccessorMock.Setup(c => c.HttpContext.Request.Host).Returns(new HostString("segment.com")); + var headerDictionary = new HeaderDictionary(); + if(addReferer) + headerDictionary.Add("Referer", "http://segment.com"); + if(addUserAgent) + headerDictionary.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"); + contextAccessorMock.Setup(c => c.HttpContext.Request.Headers).Returns(headerDictionary); + return contextAccessorMock; + } + } +} diff --git a/test/SegmentDotNet.Tests/Properties/AssemblyInfo.cs b/test/SegmentDotNet.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..73a4864 --- /dev/null +++ b/test/SegmentDotNet.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("SegmentDotNet.Tests")] +[assembly: AssemblyTrademark("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("d5950492-e758-4eb5-ae16-f5d4bcf1ec85")] diff --git a/test/SegmentDotNet.Tests/PropertyContainers/Serializers/PropertyContainerBaseSerializerTests.cs b/test/SegmentDotNet.Tests/PropertyContainers/Serializers/PropertyContainerBaseSerializerTests.cs new file mode 100644 index 0000000..08c912f --- /dev/null +++ b/test/SegmentDotNet.Tests/PropertyContainers/Serializers/PropertyContainerBaseSerializerTests.cs @@ -0,0 +1,30 @@ +namespace SegmentDotNet.Tests.PropertyContainers.Serializers +{ + using Moq; + using SegmentDotNet.PropertyContainers.Interfaces; + using SegmentDotNet.PropertyContainers.Serializers; + using System; + using Xunit; + + public class PropertyContainerBaseSerializerTests + { + [Fact] + public void Cannot_ReadJson() + { + Assert.Throws(() => new PropertyContainerBaseSerializer().ReadJson(null, null, null, null)); + } + + [Fact] + public void Valid_CanConvert() + { + var propertyContainer = new Mock(); + Assert.True(new PropertyContainerBaseSerializer().CanConvert(propertyContainer.Object.GetType())); + } + + [Fact] + public void Inalid_CanConvert() + { + Assert.False(new PropertyContainerBaseSerializer().CanConvert(typeof(string))); + } + } +} diff --git a/test/SegmentDotNet.Tests/SegmentDotNet.Tests.xproj b/test/SegmentDotNet.Tests/SegmentDotNet.Tests.xproj new file mode 100644 index 0000000..a88a96d --- /dev/null +++ b/test/SegmentDotNet.Tests/SegmentDotNet.Tests.xproj @@ -0,0 +1,22 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + d5950492-e758-4eb5-ae16-f5d4bcf1ec85 + SegmentDotNet.Tests + .\obj + .\bin\ + v4.6 + + + 2.0 + + + + + + \ No newline at end of file diff --git a/test/SegmentDotNet.Tests/TestBase.cs b/test/SegmentDotNet.Tests/TestBase.cs new file mode 100644 index 0000000..6eee0ad --- /dev/null +++ b/test/SegmentDotNet.Tests/TestBase.cs @@ -0,0 +1,42 @@ +namespace SegmentDotNet.Tests +{ + using Moq; + using Abstractions; + using SegmentDotNet.Client.Request.Abstract; + using System; + using SegmentDotNet.Client; + using Configuration; + using Microsoft.Extensions.Options; + public abstract class TestBase + { + protected SegmentClient SetupClient(string writeKey = null) + { + if (writeKey == null) + { + writeKey = Environment.GetEnvironmentVariable("SEGMENT_WRITE_KEY"); + } + + var optionsMock = new Mock>(); + var configurationMock = new Mock(); + configurationMock.Setup(c => c.WriteKey).Returns(writeKey); + optionsMock.Setup(o => o.Value).Returns(configurationMock.Object); + var segmentClient = new SegmentClient(optionsMock.Object); + return segmentClient; + } + + protected T SetupRequest() + where T : UserTimestampBase + { + var datetimeMock = new Mock(); + datetimeMock.Setup(d => d.UtcNow).Returns(new DateTime(2016, 6, 12, 5, 20, 50, 523, DateTimeKind.Utc)); + return Activator.CreateInstance(typeof(T), datetimeMock.Object) as T; + } + + protected IDateTime GetDateTimeMock() + { + var datetimeMock = new Mock(); + datetimeMock.Setup(d => d.UtcNow).Returns(new DateTime(2016, 6, 12, 5, 20, 50, 523, DateTimeKind.Utc)); + return datetimeMock.Object; + } + } +} diff --git a/test/SegmentDotNet.Tests/project.json b/test/SegmentDotNet.Tests/project.json new file mode 100644 index 0000000..073b139 --- /dev/null +++ b/test/SegmentDotNet.Tests/project.json @@ -0,0 +1,28 @@ +{ + "version": "0.1.0-*", + + "dependencies": { + "NETStandard.Library": "1.6.0", + "dotnet-test-xunit": "2.2.0-preview2-build1029", + "xunit": "2.2.0-beta2-build3300", + "Moq": "4.6.25-alpha", + "SegmentDotNet": "0.1.0-*", + "Microsoft.AspNetCore.Http": "1.0.0", + "System.Diagnostics.TraceSource": "4.0.0" + }, + "testRunner": "xunit", + "frameworks": { + "netcoreapp1.0": { + "imports": [ + "dnxcore50", + "portable-net45+win81" + ], + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.0.0", + "type": "platform" + } + } + } + } +} diff --git a/test/SegmentDotNet.Tests/sample.env b/test/SegmentDotNet.Tests/sample.env new file mode 100644 index 0000000..c644574 --- /dev/null +++ b/test/SegmentDotNet.Tests/sample.env @@ -0,0 +1 @@ +SEGMENT_WRITE_KEY= \ No newline at end of file