├── .dockerignore ├── .gitignore ├── .travis.yml ├── Common ├── Common.csproj └── Model │ ├── ClientModel.cs │ ├── HttpSubscription.cs │ ├── ModelBase.cs │ ├── Notification.cs │ ├── StringArayModelBinder.cs │ └── SubscriptionModels.cs ├── Dockerfile.client ├── Dockerfile.hub ├── FHIRcastSandbox.sln ├── FHIRcastSandbox.v3.ncrunchsolution ├── Hub ├── Controllers │ ├── HubController.cs │ └── LogController.cs ├── Core │ ├── IInternalHubClient.cs │ └── InternalHub.cs ├── Hub.csproj ├── Interfaces.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── README.md ├── Rules │ ├── Contexts.cs │ ├── HubSubscriptionCollection.cs │ ├── Notifications.cs │ ├── SubscriptionValidator.cs │ └── ValidateSubscriptionJob.cs ├── Startup.cs ├── Views │ └── Hub.cshtml ├── appsettings.Development.json ├── appsettings.json ├── nlog.config ├── package.json └── wwwroot │ ├── css │ ├── Style.css │ ├── bootstrap.min.css │ └── bootstrap.min.css.map │ ├── favicon.ico │ └── js │ ├── bootstrap.min.js │ ├── bootstrap.min.js.map │ ├── jquery-3.3.1.min.js │ ├── jquery-3.3.1.min.map │ ├── jquery.signalR.min.js │ ├── site.js │ └── site.min.js ├── HubandClientLauncher.bat ├── LICENSE ├── README.md ├── Tests ├── IntegrationTests.cs ├── NotificationTests.cs ├── SubscriptionTests.cs └── Tests.csproj ├── WebSubClient ├── Controllers │ ├── CallbackController.cs │ └── WebSubClientController.cs ├── Hubs │ ├── IWebSubClient.cs │ ├── InternalHubClient.cs │ └── WebSubClientHub.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── README.md ├── Rules │ └── Subscriptions.cs ├── Startup.cs ├── Views │ └── WebSubClient │ │ └── WebSubClient.cshtml ├── WebSubClient.csproj ├── appsettings.Development.json ├── appsettings.Docker.json ├── appsettings.json ├── bundleconfig.json ├── nlog.config ├── package-lock.json ├── package.json └── wwwroot │ ├── css │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── site.css │ └── site.min.css │ ├── data │ └── clientContextDefinition.json │ ├── favicon.ico │ ├── js │ ├── bootstrap.min.js │ ├── bootstrap.min.js.map │ ├── jquery-3.3.1.min.js │ ├── jquery-3.3.1.min.map │ ├── site.js │ └── site.min.js │ └── lib │ ├── bootstrap │ ├── .bower.json │ ├── LICENSE │ └── dist │ │ ├── css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap-theme.css.map │ │ ├── bootstrap-theme.min.css │ │ ├── bootstrap-theme.min.css.map │ │ ├── bootstrap.css │ │ ├── bootstrap.css.map │ │ ├── bootstrap.min.css │ │ └── bootstrap.min.css.map │ │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ │ └── js │ │ ├── bootstrap.js │ │ ├── bootstrap.min.js │ │ └── npm.js │ ├── jquery-validation-unobtrusive │ ├── .bower.json │ ├── jquery.validate.unobtrusive.js │ └── jquery.validate.unobtrusive.min.js │ ├── jquery-validation │ ├── .bower.json │ ├── LICENSE.md │ └── dist │ │ ├── additional-methods.js │ │ ├── additional-methods.min.js │ │ ├── jquery.validate.js │ │ └── jquery.validate.min.js │ ├── jquery │ ├── .bower.json │ ├── LICENSE.txt │ └── dist │ │ ├── jquery.js │ │ ├── jquery.min.js │ │ └── jquery.min.map │ └── signalr │ └── signalr.min.js └── docker-compose.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | .vs/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | 244 | # SQL Server files 245 | *.mdf 246 | *.ldf 247 | *.ndf 248 | 249 | # Business Intelligence projects 250 | *.rdl.data 251 | *.bim.layout 252 | *.bim_*.settings 253 | *.rptproj.rsuser 254 | 255 | # Microsoft Fakes 256 | FakesAssemblies/ 257 | 258 | # GhostDoc plugin setting file 259 | *.GhostDoc.xml 260 | 261 | # Node.js Tools for Visual Studio 262 | .ntvs_analysis.dat 263 | node_modules/ 264 | 265 | # Visual Studio 6 build log 266 | *.plg 267 | 268 | # Visual Studio 6 workspace options file 269 | *.opt 270 | 271 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 272 | *.vbw 273 | 274 | # Visual Studio LightSwitch build output 275 | **/*.HTMLClient/GeneratedArtifacts 276 | **/*.DesktopClient/GeneratedArtifacts 277 | **/*.DesktopClient/ModelManifest.xml 278 | **/*.Server/GeneratedArtifacts 279 | **/*.Server/ModelManifest.xml 280 | _Pvt_Extensions 281 | 282 | # Paket dependency manager 283 | .paket/paket.exe 284 | paket-files/ 285 | 286 | # FAKE - F# Make 287 | .fake/ 288 | 289 | # JetBrains Rider 290 | .idea/ 291 | *.sln.iml 292 | 293 | # CodeRush 294 | .cr/ 295 | 296 | # Python Tools for Visual Studio (PTVS) 297 | __pycache__/ 298 | *.pyc 299 | 300 | # Cake - Uncomment if you are using it 301 | # tools/** 302 | # !tools/packages.config 303 | 304 | # Tabs Studio 305 | *.tss 306 | 307 | # Telerik's JustMock configuration file 308 | *.jmconfig 309 | 310 | # BizTalk build output 311 | *.btp.cs 312 | *.btm.cs 313 | *.odx.cs 314 | *.xsd.cs 315 | 316 | # OpenCover UI analysis results 317 | OpenCover/ 318 | 319 | # Azure Stream Analytics local run output 320 | ASALocalRun/ 321 | 322 | # MSBuild Binary and Structured Log 323 | *.binlog 324 | 325 | # NVidia Nsight GPU debugger configuration file 326 | *.nvuser 327 | 328 | # MFractors (Xamarin productivity tool) working folder 329 | .mfractor/ 330 | 331 | internal-nlog.txt 332 | 333 | .vscode 334 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: csharp 2 | mono: none 3 | 4 | matrix: 5 | include: 6 | - os: linux 7 | dist: trusty 8 | sudo: required 9 | dotnet: 2.1.301 10 | 11 | script: 12 | - dotnet test Tests 13 | -------------------------------------------------------------------------------- /Common/Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Common/Model/ClientModel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace FHIRcastSandbox.Model 4 | { 5 | public class ClientModel : ModelBase { 6 | public string PatientID { get; set; } 7 | public string AccessionNumber { get; set; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Common/Model/HttpSubscription.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using Common.Model; 3 | 4 | namespace FHIRcastSandbox.Model.Http 5 | { 6 | public static class SubscriptionExtensions { 7 | public static HttpContent CreateHttpContent(this SubscriptionRequest source) { 8 | 9 | string content = $"hub.callback={source.Callback}" + 10 | $"&hub.mode={source.Mode}" + 11 | $"&hub.topic={source.Topic}" + 12 | $"&hub.secret={source.Secret}" + 13 | $"&hub.events={string.Join(",", source.Events)}" + 14 | $"&hub.lease_seconds={source.Lease_Seconds}"; 15 | 16 | StringContent httpcontent = new StringContent( 17 | content, 18 | System.Text.Encoding.UTF8, 19 | "application/x-www-form-urlencoded"); 20 | 21 | return httpcontent; 22 | } 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Common/Model/ModelBase.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace FHIRcastSandbox.Model { 4 | public abstract class ModelBase { 5 | public override string ToString() { 6 | return JsonConvert.SerializeObject(this); 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Common/Model/Notification.cs: -------------------------------------------------------------------------------- 1 | using FHIRcastSandbox.Model; 2 | using Hl7.Fhir.Model; 3 | using Hl7.Fhir.Serialization; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Linq; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.IO; 10 | using System.Text; 11 | 12 | namespace FHIRcastSandbox.Model 13 | { 14 | /// 15 | /// Represents a notification object that is either sent out to other clients or 16 | /// received from them in response to a subscribed event 17 | /// 18 | public class Notification : ModelBase 19 | { 20 | #region Properties 21 | [JsonProperty(PropertyName = "timestamp")] 22 | public DateTime Timestamp { get; set; } 23 | [JsonProperty(PropertyName = "id")] 24 | public string Id { get; set; } 25 | [JsonProperty(PropertyName = "event")] 26 | public NotificationEvent Event { get; set; } = new NotificationEvent(); 27 | #endregion 28 | 29 | #region JSON Conversions 30 | /// 31 | /// Creates the JSON string for this notification object as specified by the FHIRcast specs 32 | /// 33 | /// JSON string 34 | public string ToJson() 35 | { 36 | StringBuilder sb = new StringBuilder(); 37 | StringWriter sw = new StringWriter(sb); 38 | 39 | using (JsonWriter writer = new JsonTextWriter(sw)) 40 | { 41 | writer.Formatting = Formatting.Indented; 42 | 43 | writer.WriteStartObject(); // overall start object 44 | 45 | // Write timestamp 46 | writer.WritePropertyName("timestamp"); 47 | writer.WriteValue(Timestamp.ToString()); 48 | 49 | // Write id 50 | writer.WritePropertyName("id"); 51 | writer.WriteValue(Id); 52 | 53 | // Write event 54 | writer.WritePropertyName("event"); 55 | writer.WriteRawValue(Event.ToJson()); 56 | 57 | writer.WriteEndObject(); // overall end object 58 | 59 | return sb.ToString(); 60 | } 61 | } 62 | 63 | public static Notification FromJson(string jsonString) 64 | { 65 | Notification notification = new Notification(); 66 | 67 | try 68 | { 69 | JObject jObject = JObject.Parse(jsonString); 70 | 71 | notification.Timestamp = DateTime.Parse(jObject["timestamp"].ToString()); 72 | notification.Id = jObject["id"].ToString(); 73 | 74 | JObject eventObj = jObject["event"].ToObject(); 75 | notification.Event = NotificationEvent.FromJson(eventObj); 76 | } 77 | catch (Exception ex) 78 | { 79 | return null; 80 | } 81 | 82 | return notification; 83 | } 84 | #endregion 85 | 86 | #region Overrides 87 | public override bool Equals(object obj) 88 | { 89 | try 90 | { 91 | Notification that = (Notification)obj; 92 | 93 | if (this.Id != that.Id) return false; 94 | // For now just check Id but maybe add more checks later 95 | } 96 | catch (Exception) 97 | { 98 | return false; 99 | } 100 | 101 | return true; 102 | } 103 | 104 | public bool Equals(object obj, bool deepEquals) 105 | { 106 | if (!deepEquals) 107 | { 108 | return this.Equals(obj); 109 | } 110 | 111 | // Check id to start 112 | if (!this.Equals(obj)) 113 | { 114 | return false; 115 | } 116 | 117 | try 118 | { 119 | Notification that = (Notification)obj; 120 | 121 | if (this.Timestamp.Equals(that.Timestamp)) return false; 122 | if (!this.Event.Equals(that.Event)) return false; 123 | } 124 | catch (Exception) 125 | { 126 | return false; 127 | } 128 | 129 | return true; 130 | } 131 | 132 | public override string ToString() 133 | { 134 | string newline = Environment.NewLine; 135 | return $"timestamp: {Timestamp} {newline}" + 136 | $"id: {Id} {newline}" + 137 | $"event: {Event.ToString()}"; 138 | } 139 | #endregion 140 | } 141 | 142 | /// 143 | /// Represents the event object within the overall notification object. 144 | /// This contains the hub.topic, hub.event, and context array of FHIR resources 145 | /// 146 | public class NotificationEvent 147 | { 148 | #region Properties 149 | [ModelBinder(Name = "hub.topic")] 150 | [JsonProperty(PropertyName = "hub.topic")] 151 | public string Topic { get; set; } 152 | 153 | [ModelBinder(Name = "hub.event")] 154 | [JsonProperty(PropertyName = "hub.event")] 155 | public string Event { get; set; } 156 | 157 | [JsonProperty(PropertyName = "context")] 158 | public Resource[] Context { get; set; } 159 | #endregion 160 | 161 | #region JSON Conversions 162 | /// 163 | /// Creates the JSON string for this notification event object as specified by the FHIRcast specs 164 | /// 165 | /// JSON string 166 | public string ToJson() 167 | { 168 | StringBuilder sb = new StringBuilder(); 169 | StringWriter sw = new StringWriter(sb); 170 | 171 | using (JsonWriter writer = new JsonTextWriter(sw)) 172 | { 173 | writer.Formatting = Formatting.Indented; 174 | 175 | writer.WriteStartObject(); 176 | 177 | // Write hub.topic 178 | writer.WritePropertyName("hub.topic"); 179 | writer.WriteValue(Topic); 180 | 181 | // Write hub.event 182 | writer.WritePropertyName("hub.event"); 183 | writer.WriteValue(Event); 184 | 185 | // Write context 186 | writer.WritePropertyName("context"); 187 | writer.WriteStartArray(); 188 | 189 | foreach (Resource resource in Context) 190 | { 191 | writer.WriteStartObject(); 192 | 193 | writer.WritePropertyName("key"); 194 | writer.WriteValue(resource.TypeName.ToLower()); 195 | 196 | writer.WritePropertyName("resource"); 197 | FhirJsonSerializationSettings settings = new FhirJsonSerializationSettings(); 198 | settings.Pretty = true; 199 | writer.WriteRawValue(resource.ToJson(settings)); 200 | 201 | writer.WriteEndObject(); 202 | } 203 | 204 | writer.WriteEndArray(); 205 | writer.WriteEndObject(); 206 | 207 | return sb.ToString(); 208 | } 209 | } 210 | 211 | internal static NotificationEvent FromJson(JObject eventObj) 212 | { 213 | NotificationEvent notificationEvent = new NotificationEvent(); 214 | 215 | try 216 | { 217 | notificationEvent.Topic = eventObj["hub.topic"].ToString(); 218 | notificationEvent.Event = eventObj["hub.event"].ToString(); 219 | 220 | List resources = new List(); 221 | JArray context = JArray.FromObject(eventObj["context"]); 222 | foreach (JObject resource in context) 223 | { 224 | JObject fhirResource = resource["resource"].ToObject(); 225 | if (resource["key"].ToString() == "patient") 226 | { 227 | Patient patient = new Patient(); 228 | patient.Id = fhirResource["id"].ToString(); 229 | resources.Add(patient); 230 | } 231 | else if (resource["key"].ToString() == "imagingstudy") 232 | { 233 | ImagingStudy study = new ImagingStudy(); 234 | study.Id = fhirResource["id"].ToString(); 235 | resources.Add(study); 236 | } 237 | } 238 | 239 | notificationEvent.Context = resources.ToArray(); 240 | } 241 | catch (Exception) 242 | { 243 | return null; 244 | } 245 | 246 | return notificationEvent; 247 | } 248 | #endregion 249 | 250 | #region Overrides 251 | public override bool Equals(object obj) 252 | { 253 | try 254 | { 255 | NotificationEvent that = (NotificationEvent)obj; 256 | 257 | if (this.Topic != that.Topic) return false; 258 | if (this.Event != that.Event) return false; 259 | 260 | // Verify context equality 261 | if (this.Context.Length != that.Context.Length) return false; 262 | foreach (Resource thisResource in this.Context) 263 | { 264 | bool included = false; 265 | foreach (Resource thatResource in that.Context) 266 | { 267 | if (thisResource.ResourceType == thatResource.ResourceType) 268 | { 269 | if (thisResource.Id == thatResource.Id) 270 | { 271 | included = true; 272 | break; 273 | } 274 | } 275 | } 276 | if (!included) return false; 277 | } 278 | } 279 | catch (Exception) 280 | { 281 | return false; 282 | } 283 | 284 | return true; 285 | } 286 | 287 | public override string ToString() 288 | { 289 | string newline = Environment.NewLine; 290 | string context = ""; 291 | foreach (Resource resource in Context) 292 | { 293 | context += $"{resource.ResourceType}: {resource.Id} {newline}"; 294 | } 295 | return $"hub.topic: {Topic} {newline}" + 296 | $"hub.event: {Event} {newline}" + 297 | $"context: {context}"; 298 | } 299 | #endregion 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /Common/Model/StringArayModelBinder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc.ModelBinding; 4 | 5 | namespace FHIRcastSandbox.Model { 6 | public class EventsArrayModelBinder : IModelBinder { 7 | private static readonly char[] SplitCharacters = new[] { ',' }; 8 | public Task BindModelAsync(ModelBindingContext bindingContext) { 9 | var rawInputString = bindingContext.ValueProvider.GetValue("hub.events").FirstValue; 10 | 11 | if (string.IsNullOrEmpty(rawInputString)) { 12 | bindingContext.Result = ModelBindingResult.Failed(); 13 | return Task.CompletedTask; 14 | } 15 | 16 | bindingContext.Result = ModelBindingResult.Success(rawInputString.Split(SplitCharacters, StringSplitOptions.RemoveEmptyEntries)); 17 | return Task.CompletedTask; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /Common/Model/SubscriptionModels.cs: -------------------------------------------------------------------------------- 1 | using FHIRcastSandbox.Model; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.ModelBinding; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Net.Http; 7 | using System.Text; 8 | using System.Web; 9 | 10 | namespace Common.Model 11 | { 12 | /// 13 | /// Base class that contains the properties used by all Subscription interactions 14 | /// 15 | public abstract class SubscriptionBase : ModelBase 16 | { 17 | #region Properties 18 | [BindRequired] 19 | public SubscriptionMode Mode { get; set; } 20 | 21 | [BindRequired] 22 | public string Topic { get; set; } 23 | 24 | [BindRequired] 25 | [ModelBinder(typeof(EventsArrayModelBinder))] 26 | public string[] Events { get; set; } 27 | 28 | public int Lease_Seconds { get; set; } 29 | #endregion 30 | 31 | #region Overrides 32 | /// 33 | /// Tests for equality based on the topic and the events subscribed to 34 | /// 35 | /// Object comparing to 36 | /// True if the objects are equal 37 | public override bool Equals(object obj) 38 | { 39 | if ((obj == null) || (!(obj is SubscriptionBase))) 40 | { 41 | return false; 42 | } 43 | else 44 | { 45 | // Compare topic and the events (order doesn't matter here) 46 | SubscriptionBase that = (SubscriptionBase)obj; 47 | return Topic.Equals(that.Topic) && new HashSet(Events).SetEquals(that.Events); 48 | } 49 | } 50 | 51 | /// 52 | /// Not really used, but added for completeness 53 | /// 54 | /// 55 | public override int GetHashCode() 56 | { 57 | var hashCode = -50061919; 58 | hashCode = hashCode * -1521134295 + Mode.GetHashCode(); 59 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Topic); 60 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Events); 61 | hashCode = hashCode * -1521134295 + Lease_Seconds.GetHashCode(); 62 | return hashCode; 63 | } 64 | #endregion 65 | } 66 | 67 | /// 68 | /// Represents a subscription request. This can be for a new/updated subscription request 69 | /// or to unsubscribe an existing subscription. 70 | /// 71 | public class SubscriptionRequest : SubscriptionBase 72 | { 73 | #region Properties 74 | [BindRequired] 75 | public string Callback { get; set; } 76 | 77 | [BindRequired] 78 | public string Secret { get; set; } 79 | 80 | public SubscriptionChannelType? ChannelType { get; set; } 81 | 82 | [BindNever] 83 | public HubDetails HubDetails { get; set; } 84 | #endregion 85 | 86 | #region Public Methods 87 | /// 88 | /// This builds the HTTP content used in subscription requests as per the FHIRcast standard. 89 | /// Used for requesting new subscriptions or unsubscribing 90 | /// 91 | /// StringContent containing the SubscriptionRequest properties to be used in subscription requests 92 | public HttpContent BuildPostHttpContent() 93 | { 94 | string content = $"hub.callback={Callback}" + 95 | $"&hub.mode={Mode}" + 96 | $"&hub.topic={Topic}" + 97 | $"&hub.secret={Secret}" + 98 | $"&hub.events={string.Join(",", Events)}" + 99 | $"&hub.lease_seconds={Lease_Seconds}"; 100 | 101 | return new StringContent(content, Encoding.UTF8, "application/x-www-form-urlencoded"); 102 | } 103 | #endregion 104 | 105 | #region Overrides 106 | /// 107 | /// Tests for equality based on the callback, then falls back to the base equality comparison 108 | /// 109 | /// Object comparing to 110 | /// True if the objects are equal 111 | public override bool Equals(object obj) 112 | { 113 | if ((obj == null) || (!(obj is SubscriptionBase))) 114 | { 115 | return false; 116 | } 117 | else 118 | { 119 | if (obj is SubscriptionRequest) 120 | { 121 | SubscriptionRequest that = (SubscriptionRequest)obj; 122 | if (!Callback.Equals(that.Callback)) 123 | { 124 | return false; 125 | } 126 | } 127 | return base.Equals(obj); 128 | } 129 | } 130 | 131 | /// 132 | /// Not really used, but added for completeness 133 | /// 134 | /// 135 | public override int GetHashCode() 136 | { 137 | var hashCode = -1510589517; 138 | hashCode = hashCode * -1521134295 + base.GetHashCode(); 139 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Callback); 140 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Secret); 141 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(ChannelType); 142 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(HubDetails); 143 | return hashCode; 144 | } 145 | #endregion 146 | } 147 | 148 | /// 149 | /// Represents a subscription verification that is sent or received in response to a subscription request. 150 | /// Will either contain the contents of the original subscription request with a challenge that the subscriber 151 | /// uses to verify the subscription, or it will have Mode set to "denied" with a optional Reason why the 152 | /// subscription request was denied. 153 | /// 154 | public class SubscriptionVerification : SubscriptionBase 155 | { 156 | #region Properties 157 | public string Challenge { get; set; } 158 | 159 | public string Reason { get; set; } 160 | 161 | public SubscriptionRequest SubscriptionRequest { get; private set; } 162 | #endregion 163 | 164 | #region Public Methods 165 | /// 166 | /// Builds the Uri to be sent to the subscriber. Refer to SubscriptionValidator 167 | /// 168 | /// 169 | public Uri VerificationURI() 170 | { 171 | List queryParams = new List(); 172 | queryParams.Add($"hub.mode={HttpUtility.UrlEncode(this.Mode.ToString())}"); 173 | queryParams.Add($"hub.topic={HttpUtility.UrlEncode(this.Topic)}"); 174 | queryParams.Add($"hub.events={HttpUtility.UrlEncode(String.Join(",", this.Events))}"); 175 | queryParams.Add($"hub.lease_seconds={HttpUtility.UrlEncode(this.Lease_Seconds.ToString())}"); 176 | 177 | if (this.Mode == SubscriptionMode.denied) 178 | { 179 | queryParams.Add($"hub.reason={HttpUtility.UrlEncode(this.Reason)}"); 180 | } 181 | else if (this.Mode == SubscriptionMode.subscribe) 182 | { 183 | queryParams.Add($"hub.challenge={HttpUtility.UrlEncode(this.Challenge)}"); 184 | } 185 | 186 | var verificationUri = new UriBuilder(SubscriptionRequest.Callback); 187 | verificationUri.Query += String.Join("&", queryParams.ToArray()); 188 | 189 | return verificationUri.Uri; 190 | } 191 | 192 | /// 193 | /// Creates a subscription verification object based on a subscription request. 194 | /// 195 | /// 196 | /// 197 | /// 198 | public static SubscriptionVerification CreateSubscriptionVerification(SubscriptionRequest subscriptionRequest, bool denied = false) 199 | { 200 | SubscriptionVerification verification = new SubscriptionVerification() 201 | { 202 | SubscriptionRequest = subscriptionRequest, 203 | Mode = denied ? SubscriptionMode.denied : subscriptionRequest.Mode, 204 | Topic = subscriptionRequest.Topic, 205 | Events = subscriptionRequest.Events, 206 | Lease_Seconds = subscriptionRequest.Lease_Seconds 207 | }; 208 | 209 | if (denied) 210 | { 211 | verification.Reason = "Because I said so!"; 212 | } 213 | else 214 | { 215 | verification.Challenge = Guid.NewGuid().ToString("n"); 216 | } 217 | 218 | return verification; 219 | } 220 | #endregion 221 | } 222 | public class HubDetails 223 | { 224 | public string HubUrl { get; set; } 225 | public string[] HttpHeaders { get; set; } 226 | } 227 | public enum SubscriptionMode 228 | { 229 | subscribe, 230 | unsubscribe, 231 | denied, 232 | } 233 | 234 | public enum SubscriptionChannelType 235 | { 236 | websocket 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /Dockerfile.client: -------------------------------------------------------------------------------- 1 | # build image 2 | FROM microsoft/dotnet:2.2-sdk AS build 3 | WORKDIR /app 4 | 5 | COPY . . 6 | RUN dotnet restore WebSubClient/WebSubClient.csproj 7 | 8 | COPY . . 9 | RUN dotnet publish WebSubClient/WebSubClient.csproj --output /out/ --configuration Release 10 | 11 | # runtime image 12 | FROM microsoft/dotnet:2.2-aspnetcore-runtime 13 | WORKDIR /app 14 | COPY --from=build /out . 15 | ENV ASPNETCORE_ENVIRONMENT=Docker 16 | ENTRYPOINT [ "dotnet", "WebSubClient.dll" ] 17 | 18 | EXPOSE 80 19 | -------------------------------------------------------------------------------- /Dockerfile.hub: -------------------------------------------------------------------------------- 1 | # build image 2 | FROM microsoft/dotnet:2.2-sdk AS build 3 | WORKDIR /app 4 | 5 | COPY . . 6 | RUN dotnet restore Hub/Hub.csproj 7 | 8 | COPY . . 9 | RUN dotnet publish Hub/Hub.csproj --output /out/ --configuration Release 10 | 11 | # runtime image 12 | FROM microsoft/dotnet:2.2-aspnetcore-runtime 13 | WORKDIR /app 14 | COPY --from=build /out . 15 | ENTRYPOINT [ "dotnet", "Hub.dll" ] 16 | 17 | EXPOSE 80 18 | -------------------------------------------------------------------------------- /FHIRcastSandbox.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio 15 3 | VisualStudioVersion = 15.0.27130.2003 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hub", "Hub\Hub.csproj", "{45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}" 6 | EndProject 7 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{98C5E62C-3C9A-4133-A9AD-FE92A657946E}" 8 | EndProject 9 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSubClient", "WebSubClient\WebSubClient.csproj", "{30974D35-F3BF-467A-A986-4B6762446EB2}" 10 | EndProject 11 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "Common\Common.csproj", "{04E783BC-C113-4E33-AEEE-8781D0244CC1}" 12 | EndProject 13 | Global 14 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 15 | Debug|Any CPU = Debug|Any CPU 16 | Debug|x64 = Debug|x64 17 | Debug|x86 = Debug|x86 18 | Release|Any CPU = Release|Any CPU 19 | Release|x64 = Release|x64 20 | Release|x86 = Release|x86 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Debug|x64.ActiveCfg = Debug|Any CPU 26 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Debug|x64.Build.0 = Debug|Any CPU 27 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Debug|x86.ActiveCfg = Debug|Any CPU 28 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Debug|x86.Build.0 = Debug|Any CPU 29 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Release|x64.ActiveCfg = Release|Any CPU 32 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Release|x64.Build.0 = Release|Any CPU 33 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Release|x86.ActiveCfg = Release|Any CPU 34 | {45CEC24F-A1BF-4FDE-89B9-AF028CD16B55}.Release|x86.Build.0 = Release|Any CPU 35 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Debug|x64.ActiveCfg = Debug|Any CPU 38 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Debug|x64.Build.0 = Debug|Any CPU 39 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Debug|x86.ActiveCfg = Debug|Any CPU 40 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Debug|x86.Build.0 = Debug|Any CPU 41 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Release|x64.ActiveCfg = Release|Any CPU 44 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Release|x64.Build.0 = Release|Any CPU 45 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Release|x86.ActiveCfg = Release|Any CPU 46 | {98C5E62C-3C9A-4133-A9AD-FE92A657946E}.Release|x86.Build.0 = Release|Any CPU 47 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Debug|x64.ActiveCfg = Debug|Any CPU 50 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Debug|x64.Build.0 = Debug|Any CPU 51 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Debug|x86.ActiveCfg = Debug|Any CPU 52 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Debug|x86.Build.0 = Debug|Any CPU 53 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Release|x64.ActiveCfg = Release|Any CPU 56 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Release|x64.Build.0 = Release|Any CPU 57 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Release|x86.ActiveCfg = Release|Any CPU 58 | {30974D35-F3BF-467A-A986-4B6762446EB2}.Release|x86.Build.0 = Release|Any CPU 59 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Debug|x64.ActiveCfg = Debug|Any CPU 62 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Debug|x64.Build.0 = Debug|Any CPU 63 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Debug|x86.ActiveCfg = Debug|Any CPU 64 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Debug|x86.Build.0 = Debug|Any CPU 65 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Release|x64.ActiveCfg = Release|Any CPU 68 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Release|x64.Build.0 = Release|Any CPU 69 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Release|x86.ActiveCfg = Release|Any CPU 70 | {04E783BC-C113-4E33-AEEE-8781D0244CC1}.Release|x86.Build.0 = Release|Any CPU 71 | EndGlobalSection 72 | GlobalSection(SolutionProperties) = preSolution 73 | HideSolutionNode = FALSE 74 | EndGlobalSection 75 | GlobalSection(ExtensibilityGlobals) = postSolution 76 | SolutionGuid = {C432F053-0BEC-499F-A70A-434647070355} 77 | EndGlobalSection 78 | EndGlobal 79 | -------------------------------------------------------------------------------- /FHIRcastSandbox.v3.ncrunchsolution: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Hub\**.* 5 | WebSubClient\**.* 6 | 7 | True 8 | True 9 | 10 | -------------------------------------------------------------------------------- /Hub/Controllers/HubController.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Model; 3 | using FHIRcastSandbox.Rules; 4 | using Hangfire; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Extensions.Logging; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.IO; 10 | using System.Net.Http; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | 14 | namespace FHIRcastSandbox.Controllers 15 | { 16 | [Route("api/[controller]")] 17 | public class HubController : Controller 18 | { 19 | private readonly ILogger logger; 20 | private readonly IBackgroundJobClient backgroundJobClient; 21 | private readonly ISubscriptions subscriptions; 22 | private readonly INotifications notifications; 23 | private readonly IContexts contexts; 24 | 25 | public HubController(ILogger logger, IBackgroundJobClient backgroundJobClient, ISubscriptions subscriptions, INotifications notifications, IContexts contexts) 26 | { 27 | this.backgroundJobClient = backgroundJobClient ?? throw new ArgumentNullException(nameof(backgroundJobClient)); 28 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 29 | this.subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); 30 | this.notifications = notifications ?? throw new ArgumentNullException(nameof(notifications)); 31 | this.contexts = contexts ?? throw new ArgumentNullException(nameof(contexts)); 32 | } 33 | 34 | /// 35 | /// Adds a subscription to this hub. 36 | /// 37 | /// The subscription parameters. 38 | /// if set to true simulate cancelling/denying the subscription by sending this to the callback url. 39 | /// 40 | [HttpPost] 41 | public IActionResult Subscribe([FromForm]SubscriptionRequest hub, bool _cancel = false) 42 | { 43 | logger.LogDebug($"Model valid state is {this.ModelState.IsValid}"); 44 | foreach (var modelProperty in this.ModelState) 45 | { 46 | if (modelProperty.Value.Errors.Count > 0) 47 | { 48 | for (int i = 0; i < modelProperty.Value.Errors.Count; i++) 49 | { 50 | logger.LogDebug($"Error found for {modelProperty.Key}: {modelProperty.Value.Errors[i].ErrorMessage}"); 51 | } 52 | } 53 | } 54 | 55 | logger.LogDebug($"Subscription for 'received hub subscription': {Environment.NewLine}{hub}"); 56 | 57 | if (!ModelState.IsValid) 58 | { 59 | return BadRequest(ModelState); 60 | } 61 | 62 | backgroundJobClient.Enqueue(job => job.Run(hub, _cancel)); 63 | 64 | return Accepted(); 65 | } 66 | 67 | /// 68 | /// Gets all active subscriptions. 69 | /// 70 | /// All active subscriptions. 71 | [HttpGet] 72 | public IEnumerable GetSubscriptions() 73 | { 74 | return subscriptions.GetActiveSubscriptions(); 75 | } 76 | 77 | /// 78 | /// Sets a context for a certain topic. 79 | /// 80 | /// 81 | [Route("{topicId}")] 82 | [HttpPost] 83 | public async Task Notify(string topicId) 84 | { 85 | Notification notification; 86 | using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8)) 87 | { 88 | notification = Notification.FromJson(await reader.ReadToEndAsync()); 89 | } 90 | 91 | logger.LogInformation($"Got notification from client: {notification}"); 92 | 93 | var subscriptions = this.subscriptions.GetSubscriptions(notification.Event.Topic, notification.Event.Event); 94 | logger.LogDebug($"Found {subscriptions.Count} subscriptions matching client event"); 95 | 96 | if (subscriptions.Count == 0) 97 | { 98 | return NotFound($"Could not find any subscriptions for sessionId {topicId}."); 99 | } 100 | 101 | contexts.setContext(topicId, notification.Event.Context); 102 | 103 | var success = true; 104 | foreach (var sub in subscriptions) 105 | { 106 | success |= (await notifications.SendNotification(notification, sub)).IsSuccessStatusCode; 107 | } 108 | if (!success) 109 | { 110 | // TODO: return reason for failure 111 | Forbid(); 112 | } 113 | return Ok(); 114 | } 115 | 116 | /// 117 | /// TODO: Looks like the query for current context functionality. Not sure where this will be after 118 | /// ballot resolution so look into this later. 119 | /// 120 | /// 121 | /// 122 | [Route("{topicId}")] 123 | [HttpGet] 124 | public object GetCurrentcontext(string topicId) 125 | { 126 | logger.LogInformation($"Got context request from for : {topicId}"); 127 | 128 | var context = contexts.getContext(topicId); 129 | 130 | if (context != null) 131 | { 132 | return context; 133 | } 134 | else 135 | { 136 | return NotFound(); 137 | } 138 | 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Hub/Controllers/LogController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using NLog.Targets; 3 | using NLog; 4 | using System.Collections.Generic; 5 | using System.IO; 6 | using System.Reflection; 7 | using System; 8 | 9 | namespace FHIRcastSandbox.Controllers { 10 | [Route("api/[controller]")] 11 | public class LogController : Controller { 12 | [HttpGet] 13 | [Route("{log}")] 14 | public IActionResult Get(string log) { 15 | var target = LogManager.Configuration.FindTargetByName(log); 16 | if (target != null) { 17 | var logFile = target.FileName.Render(new LogEventInfo()); 18 | var logDir = Path.GetDirectoryName(new Uri(Assembly.GetExecutingAssembly().Location).AbsolutePath); 19 | 20 | return this.Content(System.IO.File.ReadAllText(Path.Combine(logDir, logFile))); 21 | } else { 22 | return this.NotFound("No such log file found..."); 23 | } 24 | } 25 | 26 | [HttpGet] 27 | public IActionResult Get() { 28 | return this.Get("fhircast"); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Hub/Core/IInternalHubClient.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using System.Threading.Tasks; 3 | 4 | namespace FHIRcastSandbox.Core 5 | { 6 | /// 7 | /// Interface to create a strongly typed SignalR hub from InternalHub to InternalHubClient (these are the messages 8 | /// the Hub project would need to send to the WebSubClient project, mainly about subscriptions received for a 9 | /// specific client). 10 | /// 11 | public interface IInternalHubClient 12 | { 13 | Task AddSubscriber(SubscriptionRequest subscription); 14 | Task RemoveSubscriber(SubscriptionRequest subscription); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Hub/Core/InternalHub.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using Microsoft.AspNetCore.SignalR; 3 | using Microsoft.Extensions.Logging; 4 | using System.Collections.Generic; 5 | using System.Threading.Tasks; 6 | 7 | namespace FHIRcastSandbox.Core 8 | { 9 | /// 10 | /// SignalR hub for communication with WebSubClient clients. The main communication will be around 11 | /// new subscriptions to those clients (which come in through the Hub) and notifications of client 12 | /// updates that affect those subscriptions. 13 | /// 14 | /// TODO: Add notification for unsubscriber 15 | /// TODO: Move notifications from client to go through here instead of using the post mechanism 16 | /// TODO: Unit tests for the SignalR connection. No updated unit test documentation for 17 | /// SignalR in ASP.NET Core that I could find 18 | /// 19 | public class InternalHub : Hub 20 | { 21 | /// 22 | /// The topic for the client(which is currently the signalr connectionid between the websubclienthub and 23 | /// the javascript client) is different than this signalr connectionid so we need a mapping between the two. 24 | /// 25 | /// TODO: Should the topic for the client be assigned by this hub? so when the client first starts up it asks 26 | /// for its topic id which could then be this signalr hub's connectionid meaning we don't need this collection? 27 | /// 28 | Dictionary topicConnectionIdMapping = new Dictionary(); 29 | 30 | private readonly ILogger logger; 31 | 32 | public InternalHub(ILogger logger) 33 | { 34 | this.logger = logger; 35 | } 36 | 37 | #region Calls from Client 38 | /// 39 | /// Called by the client (WebSubClient) to register their topic so the InternalHub knows which client 40 | /// to talk to given a topic received from an outside caller (i.e. subscription request). 41 | /// 42 | /// 43 | public void RegisterTopic(string topic) 44 | { 45 | logger.LogDebug($"Registering topic {topic} with InternalHub"); 46 | if (!topicConnectionIdMapping.ContainsKey(topic)) 47 | { 48 | topicConnectionIdMapping.Add(topic, Context.ConnectionId); 49 | } 50 | } 51 | #endregion 52 | 53 | #region Calls to Client 54 | /// 55 | /// Called when we have validated a subscription request. Informs the client of their new subscriber 56 | /// 57 | /// Topic being subscribed to 58 | /// Subscription object 59 | /// 60 | public Task NotifyClientOfSubscriber(string topic, SubscriptionRequest subscription) 61 | { 62 | if (!topicConnectionIdMapping.ContainsKey(topic)) 63 | { 64 | logger.LogError($"Could not find a client connection associated with topic {topic}"); 65 | return null; 66 | } 67 | 68 | logger.LogDebug($"Notifying {topic} of new subscriber {subscription.ToString()}"); 69 | return Clients.Client(topicConnectionIdMapping[topic]).AddSubscriber(subscription); 70 | } 71 | #endregion 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Hub/Hub.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.1 5 | 6 | FHIRcastSandbox.Hub 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | PreserveNewest 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /Hub/Interfaces.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Model; 3 | using FHIRcastSandbox.Rules; 4 | using System.Collections.Generic; 5 | using System.Threading.Tasks; 6 | 7 | namespace FHIRcastSandbox 8 | { 9 | public interface ISubscriptionValidator { 10 | Task ValidateSubscription(SubscriptionRequest subscription, HubValidationOutcome outcome); 11 | } 12 | 13 | public interface ISubscriptions { 14 | ICollection GetActiveSubscriptions(); 15 | void AddSubscription(SubscriptionRequest subscription); 16 | void RemoveSubscription(SubscriptionRequest subscription); 17 | ICollection GetSubscriptions(string topic, string notificationEvent); 18 | } 19 | 20 | public interface INotifications { 21 | Task SendNotification(Notification notification, SubscriptionRequest subscription); 22 | } 23 | public interface IContexts 24 | { 25 | string addContext(); 26 | void setContext(string topic, object context); 27 | object getContext(string topic); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Hub/Program.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.MemoryStorage; 2 | using Hangfire; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.AspNetCore; 5 | using NLog.Web; 6 | 7 | namespace FHIRcastSandbox { 8 | public class Program { 9 | public static void Main(string[] args) { 10 | BuildWebHost(args).Run(); 11 | } 12 | 13 | public static IWebHostBuilder CreateWebHostBuilder(params string[] args) { 14 | return WebHost.CreateDefaultBuilder(args) 15 | .UseStartup() 16 | .UseNLog(); 17 | } 18 | 19 | public static IWebHost BuildWebHost(string[] args) => CreateWebHostBuilder(args).Build(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Hub/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Hub": { 4 | "applicationUrl": "http://localhost:5000/", 5 | "commandName": "Project", 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "launchBrowser": false, 10 | "ssl": false 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Hub/README.md: -------------------------------------------------------------------------------- 1 | # Hub 2 | 3 | The FHIRcast Hub project is an implementation of a FHIRcast Hub with some additional APIs to make it easy to inspect and test with. 4 | 5 | ## Usage 6 | 7 | Assuming your current directory is the same as this file, you can run the Hub using: 8 | 9 | ```sh 10 | $ dotnet run 11 | ``` 12 | 13 | which will launch the Hub on http://localhost:5000 by default. 14 | 15 | ## API 16 | 17 | There are some APIs outside of FHIRcast that can be used towards the hub to inspect its current state: 18 | 19 | ### `GET /api/hub` 20 | 21 | Get the current subscriptions of the hub. 22 | -------------------------------------------------------------------------------- /Hub/Rules/Contexts.cs: -------------------------------------------------------------------------------- 1 | using FHIRcastSandbox.Model; 2 | using Microsoft.Extensions.Logging; 3 | using System.Net.Http.Headers; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Collections.Concurrent; 9 | 10 | namespace FHIRcastSandbox.Rules { 11 | public class Contexts : IContexts { 12 | private ILogger logger; 13 | 14 | private readonly IDictionary contexts; 15 | 16 | public Contexts(ILogger logger) { 17 | this.logger = logger; 18 | contexts = new ConcurrentDictionary(); 19 | } 20 | 21 | public string addContext() 22 | { 23 | int count = contexts.Count; 24 | string topic = $"topic{count++}"; 25 | contexts.Add(topic, null); 26 | return topic; 27 | } 28 | 29 | public void setContext(string topic, object context) 30 | { 31 | contexts[topic] = context; 32 | } 33 | 34 | public object getContext(string topic) 35 | { 36 | object context; 37 | if (this.contexts.TryGetValue(topic, out context)) 38 | { 39 | return context; 40 | } 41 | else 42 | { 43 | return null; 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Hub/Rules/HubSubscriptionCollection.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using Microsoft.Extensions.Logging; 3 | using System.Collections.Generic; 4 | using System.Collections.Immutable; 5 | using System.Linq; 6 | 7 | namespace FHIRcastSandbox.Rules 8 | { 9 | public class HubSubscriptionCollection : ISubscriptions 10 | { 11 | private readonly ILogger logger; 12 | private ImmutableHashSet subscriptions = ImmutableHashSet.Empty; 13 | 14 | public HubSubscriptionCollection(ILogger logger) 15 | { 16 | this.logger = logger; 17 | } 18 | 19 | public ICollection GetActiveSubscriptions() 20 | { 21 | return this.subscriptions; 22 | } 23 | 24 | public ICollection GetSubscriptions(string topic, string notificationEvent) 25 | { 26 | this.logger.LogDebug($"Finding subscriptions for topic: {topic} and event: {notificationEvent}"); 27 | return this.subscriptions 28 | .Where(x => x.Topic == topic) 29 | .Where(x => x.Events.Contains(notificationEvent)) 30 | .ToArray(); 31 | } 32 | 33 | public SubscriptionRequest GetSubscription(string topic) 34 | { 35 | return this.subscriptions.Where(x => x.Topic == topic).First(); 36 | } 37 | 38 | public void AddSubscription(SubscriptionRequest subscription) 39 | { 40 | this.logger.LogInformation($"Adding subscription {subscription}."); 41 | this.subscriptions = this.subscriptions.Add(subscription); 42 | } 43 | 44 | public void RemoveSubscription(SubscriptionRequest subscription) 45 | { 46 | this.logger.LogInformation($"Removing subscription {subscription}."); 47 | this.subscriptions = this.subscriptions.Remove(subscription); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Hub/Rules/Notifications.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Model; 3 | using Microsoft.Extensions.Logging; 4 | using System; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Security.Cryptography; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace FHIRcastSandbox.Rules 12 | { 13 | public class Notifications : INotifications 14 | { 15 | private ILogger> logger; 16 | 17 | public Notifications(ILogger> logger) 18 | { 19 | this.logger = logger; 20 | } 21 | 22 | public async Task SendNotification(Notification notification, SubscriptionRequest subscription) 23 | { 24 | // Create the JSON body 25 | string body = notification.ToJson(); 26 | HttpContent httpContent = new StringContent(body); 27 | 28 | // Add the headers 29 | httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); 30 | httpContent.Headers.Add("X-Hub-Signature", XHubSignature(subscription, body)); 31 | 32 | this.logger.LogInformation($"Sending notification: " + 33 | $"{httpContent.Headers.ToString()}" + 34 | $"{body}"); 35 | 36 | // Send notification 37 | HttpClient client = new HttpClient(); 38 | var response = await client.PostAsync(subscription.Callback, httpContent); 39 | 40 | this.logger.LogDebug($"Got response from posting notification:{Environment.NewLine}{response}{Environment.NewLine}{await response.Content.ReadAsStringAsync()}."); 41 | return response; 42 | } 43 | 44 | /// 45 | /// Calculates and returns the X-Hub-Signature header. Currently uses sha256 46 | /// 47 | /// Subscription to get the secret from 48 | /// Body used to calculate the signature 49 | /// The sha256 hash of the body using the subscription's secret 50 | private string XHubSignature(SubscriptionRequest subscription, string body) 51 | { 52 | using (HMACSHA256 sha256 = new HMACSHA256(Encoding.ASCII.GetBytes(subscription.Secret))) 53 | { 54 | byte[] bodyBytes = Encoding.UTF8.GetBytes(body); 55 | 56 | byte[] hash = sha256.ComputeHash(bodyBytes); 57 | StringBuilder stringBuilder = new StringBuilder(hash.Length * 2); 58 | foreach (byte b in hash) 59 | { 60 | stringBuilder.AppendFormat("{0:x2}", b); 61 | } 62 | 63 | return "sha256=" + stringBuilder.ToString(); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Hub/Rules/SubscriptionValidator.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using Microsoft.Extensions.Logging; 3 | using System; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | 7 | namespace FHIRcastSandbox.Rules 8 | { 9 | public class SubscriptionValidator : ISubscriptionValidator 10 | { 11 | private readonly ILogger logger = null; 12 | 13 | public SubscriptionValidator(ILogger logger) 14 | { 15 | this.logger = logger; 16 | } 17 | 18 | public async Task ValidateSubscription(SubscriptionRequest subscription, HubValidationOutcome outcome) 19 | { 20 | if (subscription == null) 21 | { 22 | throw new ArgumentNullException(nameof(subscription)); 23 | } 24 | 25 | SubscriptionVerification verification = SubscriptionVerification.CreateSubscriptionVerification(subscription, (outcome == HubValidationOutcome.Canceled)); 26 | Uri verificationUri = verification.VerificationURI(); 27 | 28 | logger.LogDebug($"Calling callback url: {verificationUri}"); 29 | var response = await new HttpClient().GetAsync(verificationUri); 30 | 31 | if (outcome == HubValidationOutcome.Canceled) 32 | { 33 | return ClientValidationOutcome.NotVerified; 34 | } 35 | else 36 | { 37 | if (!response.IsSuccessStatusCode) 38 | { 39 | logger.LogInformation($"Status code was not success but instead {response.StatusCode}"); 40 | return ClientValidationOutcome.NotVerified; 41 | } 42 | 43 | var responseBody = (await response.Content.ReadAsStringAsync()); 44 | if (responseBody != verification.Challenge) 45 | { 46 | logger.LogInformation($"Callback result for verification request was not equal to challenge. Response body: '{responseBody}', Challenge: '{verification.Challenge}'."); 47 | return ClientValidationOutcome.NotVerified; 48 | } 49 | 50 | return ClientValidationOutcome.Verified; 51 | } 52 | } 53 | } 54 | 55 | public enum HubValidationOutcome 56 | { 57 | Valid, 58 | Canceled, 59 | } 60 | 61 | public enum ClientValidationOutcome 62 | { 63 | Verified, 64 | NotVerified, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Hub/Rules/ValidateSubscriptionJob.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Core; 3 | using Microsoft.Extensions.Logging; 4 | using System.Threading.Tasks; 5 | 6 | namespace FHIRcastSandbox.Rules 7 | { 8 | public class ValidateSubscriptionJob 9 | { 10 | private readonly ISubscriptionValidator validator; 11 | private readonly ISubscriptions subscriptions; 12 | private readonly ILogger logger; 13 | private readonly InternalHub internalHub; 14 | 15 | public ValidateSubscriptionJob(ISubscriptionValidator validator, ISubscriptions subscriptions, ILogger logger, InternalHub internalHub) 16 | { 17 | this.validator = validator; 18 | this.subscriptions = subscriptions; 19 | this.logger = logger; 20 | this.internalHub = internalHub; 21 | } 22 | 23 | public async Task Run(SubscriptionRequest subscription, bool simulateCancellation) 24 | { 25 | HubValidationOutcome validationOutcome = simulateCancellation ? HubValidationOutcome.Canceled : HubValidationOutcome.Valid; 26 | var validationResult = await validator.ValidateSubscription(subscription, validationOutcome); 27 | 28 | if (validationResult == ClientValidationOutcome.Verified) 29 | { 30 | if (subscription.Mode == SubscriptionMode.subscribe) 31 | { 32 | // Add subscription to collection and inform client 33 | logger.LogInformation($"Adding verified subscription: {subscription}."); 34 | subscriptions.AddSubscription(subscription); 35 | await internalHub.NotifyClientOfSubscriber(subscription.Topic, subscription); 36 | } 37 | else if (subscription.Mode == SubscriptionMode.unsubscribe) 38 | { 39 | logger.LogInformation($"Removing verified subscription: {subscription}."); 40 | subscriptions.RemoveSubscription(subscription); 41 | } 42 | } 43 | else 44 | { 45 | var addingOrRemoving = subscription.Mode == SubscriptionMode.subscribe ? "adding" : "removing"; 46 | logger.LogInformation($"Not {addingOrRemoving} unverified subscription: {subscription}."); 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Hub/Startup.cs: -------------------------------------------------------------------------------- 1 | using FHIRcastSandbox.Rules; 2 | using Hangfire.MemoryStorage; 3 | using Hangfire; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using System; 9 | using System.Net.Http; 10 | using FHIRcastSandbox.Core; 11 | 12 | namespace FHIRcastSandbox 13 | { 14 | public class Startup { 15 | public Startup(IConfiguration configuration) { 16 | Configuration = configuration; 17 | } 18 | 19 | public IConfiguration Configuration { get; } 20 | 21 | // This method gets called by the runtime. Use this method to add services to the container. 22 | public void ConfigureServices(IServiceCollection services) { 23 | services.AddMvc(); 24 | services.AddHangfire(config => config 25 | .UseNLogLogProvider() 26 | .UseMemoryStorage()); 27 | services.AddSignalR(); 28 | services.AddTransient(); 29 | 30 | services.AddSingleton(); 31 | services.AddSingleton, Notifications>(); 32 | services.AddSingleton(); 33 | services.AddSingleton(typeof(InternalHub)); 34 | 35 | services.AddTransient(); 36 | services.AddTransient(); 37 | } 38 | 39 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 40 | public void Configure(IApplicationBuilder app, IHostingEnvironment env) { 41 | if (env.IsDevelopment()) { 42 | app.UseDeveloperExceptionPage(); 43 | } 44 | 45 | app.UseMvc(); 46 | app.UseHangfireServer(); 47 | app.UseStaticFiles(); 48 | app.UseSignalR(route => 49 | { 50 | route.MapHub("/internalHub"); 51 | }); 52 | 53 | JobActivator.Current = new ServiceProviderJobActivator(app.ApplicationServices); 54 | } 55 | } 56 | 57 | internal class ServiceProviderJobActivator : JobActivator { 58 | private IServiceProvider serviceProvider; 59 | 60 | public ServiceProviderJobActivator(IServiceProvider serviceProvider) { 61 | this.serviceProvider = serviceProvider; 62 | } 63 | 64 | public override object ActivateJob(Type jobType) { 65 | return this.serviceProvider.GetService(jobType); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Hub/Views/Hub.cshtml: -------------------------------------------------------------------------------- 1 |  2 | @{ 3 | Layout = null; 4 | } 5 | 6 | 7 | 8 | 9 | 10 | 11 | Hub 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Hub/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "LogLevel": { 5 | "Default": "Debug", 6 | "System": "Information", 7 | "Microsoft": "Information" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Hub/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "Debug": { 5 | "LogLevel": { 6 | "Default": "Warning" 7 | } 8 | }, 9 | "Console": { 10 | "LogLevel": { 11 | "Default": "Warning" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Hub/nlog.config: -------------------------------------------------------------------------------- 1 |  2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Hub/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "now": { 3 | "name": "fhircast", 4 | "alias": "sandbox" 5 | }, 6 | "scripts": { 7 | "deploy": "now -e NODE_ENV=production --token $NOW_TOKEN --npm", 8 | "alias": "now alias --token=$NOW_TOKEN" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Hub/wwwroot/css/Style.css: -------------------------------------------------------------------------------- 1 | .SignalRInput { 2 | color: navy; 3 | margin-left: 5px; 4 | width : 300px 5 | } 6 | 7 | .SignalR { 8 | font-family:Arial; 9 | font-size:smaller; 10 | } 11 | 12 | .SignalRConnected { 13 | background-color:lightgreen; 14 | } 15 | 16 | .SignalRNotConnected { 17 | background-color: indianred; 18 | } 19 | 20 | .SubscriptionFieldSet { 21 | background-color: #0d549e; 22 | color: white; 23 | font-family: Arial; 24 | font-size: large; 25 | display: inline-block; 26 | margin-left: 2px; 27 | margin-right: 20px; 28 | padding-top: 0.35em; 29 | padding-bottom: 0.625em; 30 | padding-left: 0.75em; 31 | padding-right: 0.75em; 32 | border: 2px groove; 33 | width: auto 34 | } 35 | 36 | .SubscriptionTable { 37 | width: auto; 38 | } 39 | 40 | .UserSessionFieldSet { 41 | background-color: #0d549e; 42 | color:white; 43 | font-family:Arial; 44 | font-size:large; 45 | display: inline-block; 46 | margin-left: 2px; 47 | margin-right: 20px; 48 | padding-top: 0.35em; 49 | padding-bottom: 0.625em; 50 | padding-left: 0.75em; 51 | padding-right: 0.75em; 52 | border: 2px groove; 53 | width: auto 54 | } 55 | 56 | .tooltip { 57 | position: relative; 58 | display: inline-block; 59 | } 60 | 61 | .tooltip .tooltiptext { 62 | visibility: hidden; 63 | width: 180px; 64 | background-color: #555; 65 | color: #fff; 66 | text-align: center; 67 | border-radius: 6px; 68 | padding: 5px 0; 69 | position: absolute; 70 | z-index: 1; 71 | bottom: 125%; 72 | left: 50%; 73 | margin-left: -60px; 74 | opacity: 0; 75 | transition: opacity 0.3s; 76 | } 77 | 78 | .tooltip .tooltiptext::after { 79 | content: ""; 80 | position: absolute; 81 | top: 100%; 82 | left: 50%; 83 | margin-left: -5px; 84 | border-width: 5px; 85 | border-style: solid; 86 | border-color: #555 transparent transparent transparent; 87 | } 88 | 89 | .tooltip:hover .tooltiptext { 90 | visibility: visible; 91 | opacity: 1; 92 | } 93 | 94 | td { 95 | border: 1px #DDD solid; 96 | padding: 5px; 97 | cursor: pointer; 98 | } 99 | 100 | .selected { 101 | background-color: brown; 102 | color: #FFF; 103 | } 104 | -------------------------------------------------------------------------------- /Hub/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/Hub/wwwroot/favicon.ico -------------------------------------------------------------------------------- /Hub/wwwroot/js/site.js: -------------------------------------------------------------------------------- 1 | // Write your JavaScript code. -------------------------------------------------------------------------------- /Hub/wwwroot/js/site.min.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/Hub/wwwroot/js/site.min.js -------------------------------------------------------------------------------- /HubandClientLauncher.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | IF NOT EXIST %~dp0\Hub\ ( 3 | echo Run batch file in sandbox level directory 4 | ) 5 | IF NOT EXIST %~dp0\WebSubClient\ ( 6 | echo Run batch file in sandbox level directory 7 | ) 8 | 9 | start cmd.exe /k "dotnet run --project Hub" 10 | start cmd.exe /k "dotnet run --project WebSubClient" 11 | start "" http://localhost:5001 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FHIRcast Sandbox 2 | 3 | The FHIRcast Sandbox is a tool that allows users to simulate the workflow of the [FHIRcast](http://fhircast.org/) standard. It acts as a sort of "mock"-EHR or PACS that can be used as a demonstration and testing tool for showing how FHIRcast would work with subscribers. 4 | 5 | ## How it Works 6 | 7 | 8 | ## Local development 9 | 10 | You can develop on and run this project locally by using the following steps below. The projects consists of two parts: 11 | 12 | 1. A [FHIRcast Hub](Hub) implementation with a non-standard API to show its current state. 13 | 2. A [WebSub client](WebSubClient) that can subscribe to a Hub using standard APIs and a web application that can notify other client connected to the hub as well as receive notifications from those client. 14 | 15 | ### Development without Docker (recommended) 16 | 17 | #### Setup 18 | 19 | First, install the [.NET Core SDK](http://dot.net). 20 | 21 | #### Run it 22 | 23 | In order to run the two webservers locally, run: 24 | 25 | ```sh 26 | $ dotnet run --project Hub 27 | ``` 28 | 29 | to start the Hub, and 30 | 31 | ```sh 32 | $ dotnet run --project WebSubClient 33 | ``` 34 | 35 | to start the WebSub client. On a Unix operating system you can also run both servers in the background using e.g.: 36 | 37 | ```sh 38 | $ (dotnet run --project Hub &) && (dotnet run --project WebSubClient &) 39 | ``` 40 | 41 | and on Windows you can achieve the same thing like this in PowerShell: 42 | 43 | ```powershell 44 | > "$pwd\Hub" | Start-Job { dotnet run --project $Input } -Name Hub; "$pwd\WebSubClient" | Start-Job { dotnet run --project $Input } -Name WebSubClient 45 | ``` 46 | 47 | You can then start issuing HTTP requests to the server. Here's an example using curl that will create a subscription and cause the Hub to attempt to validate your callback url (as defined in `my_url_encoded_callback`). 48 | 49 | ```sh 50 | event='switch-patient-chart' 51 | my_url_encoded_callback='http%3A%2F%2Flocalhost%3A1337' 52 | topic='some_topic' 53 | 54 | # Request a subscription on the hub. 55 | curl -d "hub.callback={my_url_encoded_callback}&hub.mode=subscribe&hub.topic={topic}&hub.secret=secret&hub.events={events}&hub.lease_seconds=3600&hub.uid=untilIssueIsFixed" -X POST http://localhost:5000/api/hub 56 | ``` 57 | 58 | To stop the background servers, run: 59 | 60 | For Unix: 61 | 62 | ```sh 63 | $ pkill dotnet 64 | ``` 65 | 66 | For Windows: 67 | 68 | ```powershell 69 | > Stop-Job Hub, WebSubClient; Remove-Job Hub, WebSubClient 70 | ``` 71 | 72 | #### Tutorial 73 | 74 | See the [in progress Tutorial](https://github.com/fhircast/sandbox/wiki/Tutorial) for a more detailed steps towards a hello world app. [Feedback](https://chat.fhir.org/#narrow/stream/118-FHIRcast) welcome (and needed)! 75 | 76 | ### Development or running with Docker 77 | 78 | In order to launch the hub and the client using docker, use docker-compose from the root of the repository like so: 79 | 80 | ``` 81 | $ docker-compose up 82 | ``` 83 | 84 | This will build the docker containers and run the application on ports 5000 (for Hub) and 5001 (for the Client). 85 | 86 | ## Build and Contribution 87 | 88 | We welcome any contributions to help further enhance this tool for the FHIRcast community! To contribute to this project, please see instructions above for running the application locally and testing the app to make sure the tool works as expected with your incorporated changes. Then follow the steps below. 89 | 90 | 1. Issue a pull request on the `fhircast/sandbox` repository with your changes for review. 91 | -------------------------------------------------------------------------------- /Tests/IntegrationTests.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Model.Http; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.Configuration; 5 | using Newtonsoft.Json; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Net; 11 | using System.Net.Http; 12 | using System.Net.Sockets; 13 | using System.Security.Cryptography; 14 | using System.Threading.Tasks; 15 | using Xunit; 16 | 17 | namespace FHIRcastSandbox 18 | { 19 | public class IntegrationTests : IDisposable 20 | { 21 | private readonly IWebHost hubServer; 22 | private readonly IWebHost webSubClientServer; 23 | private readonly int hubServerPort; 24 | private readonly int webSubClientServerPort; 25 | 26 | public IntegrationTests() 27 | { 28 | this.hubServerPort = this.GetFreePort(); 29 | this.hubServer = this.CreateHubServer(this.hubServerPort); 30 | Console.WriteLine($"Hub: http://localhost:{this.hubServerPort}"); 31 | 32 | this.webSubClientServerPort = this.GetFreePort(); 33 | this.webSubClientServer = this.CreateWebSubClientServer(this.webSubClientServerPort); 34 | Console.WriteLine($"WebSubClient: http://localhost:{this.webSubClientServerPort}"); 35 | 36 | Task.WaitAll( 37 | this.hubServer.StartAsync(), 38 | this.webSubClientServer.StartAsync()); 39 | System.Threading.Thread.Sleep(1000); 40 | } 41 | 42 | private IWebHost CreateWebSubClientServer(int port) 43 | { 44 | var contentRoot = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "WebSubClient"); 45 | return FHIRcastSandbox.WebSubClient.Program.CreateWebHostBuilder() 46 | .UseKestrel() 47 | .UseContentRoot(contentRoot) 48 | .UseUrls($"http://localhost:{port}") 49 | .ConfigureAppConfiguration((_, config) => 50 | config.AddInMemoryCollection(new Dictionary { 51 | { "Settings:ValidateSubscriptionValidations", "False" }, 52 | { "Logging:LogLevel:Default", "Warning" }, 53 | })) 54 | .Build(); 55 | } 56 | 57 | private IWebHost CreateHubServer(int port) 58 | { 59 | var contentRoot = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "Hub"); 60 | return FHIRcastSandbox.Program.CreateWebHostBuilder() 61 | .UseKestrel() 62 | .UseContentRoot(contentRoot) 63 | .UseUrls($"http://localhost:{port}") 64 | .ConfigureAppConfiguration((_, config) => 65 | config.AddInMemoryCollection(new Dictionary { 66 | { "Logging:LogLevel:Default", "Warning" }, 67 | })) 68 | .Build(); 69 | } 70 | 71 | private int GetFreePort() 72 | { 73 | TcpListener l = new TcpListener(IPAddress.Loopback, 0); 74 | l.Start(); 75 | int port = ((IPEndPoint)l.LocalEndpoint).Port; 76 | l.Stop(); 77 | return port; 78 | } 79 | 80 | private void VerifiySubscription(SubscriptionRequest sentSubscription, SubscriptionRequest returnedSubscription) 81 | { 82 | Assert.Equal(sentSubscription.Secret, returnedSubscription.Secret); 83 | Assert.Equal(sentSubscription.Mode, returnedSubscription.Mode); 84 | Assert.Equal(sentSubscription.Topic, returnedSubscription.Topic); 85 | // Server isn't setting the HubURL; should it? 86 | //Assert.Equal(sentSubscription.HubURL.URL, returnedSubscription.HubURL.URL); 87 | Assert.Equal(sentSubscription.Callback, returnedSubscription.Callback); 88 | Assert.Equal(sentSubscription.Lease_Seconds, returnedSubscription.Lease_Seconds); 89 | // Check for all events in array 90 | foreach (string sentEvent in sentSubscription.Events) 91 | { 92 | Assert.True(returnedSubscription.Events.Contains(sentEvent), $"Missing event: {sentEvent} from subscription"); 93 | } 94 | } 95 | 96 | //[Fact] 97 | // TODO: This test only sometimes passes and seems to be a timing issue. When run only by itself it always passes, but when run with the other tests 98 | // it is less reliable. Might need to refactor how these tests are created and run. 99 | public async Task ListingSubscriptions_AfterSubscribingToHub_ReturnsSubsription_Test() 100 | { 101 | // Arrange 102 | var sessionId = "some_id"; 103 | var connectionId = "some_client_connection_id"; 104 | var subscriptionUrl = $"http://localhost:{this.hubServerPort}/api/hub"; 105 | var topic = $"{subscriptionUrl}/{sessionId}"; 106 | var events = new[] { "some_event", "another_event" }; 107 | var callback = $"http://localhost:{this.webSubClientServerPort}/callback/{connectionId}"; 108 | int leaseSeconds = 2400; 109 | var subscription = CreateNewSubscription(subscriptionUrl, topic, events, callback, leaseSeconds); 110 | var httpContent = subscription.CreateHttpContent(); 111 | 112 | var clientTestResponse = await new HttpClient().GetAsync(callback); 113 | Assert.True(clientTestResponse.IsSuccessStatusCode, $"Could not connect to web sub client: {clientTestResponse}"); 114 | 115 | var subscriptionResponse = await new HttpClient().PostAsync(subscriptionUrl, httpContent); 116 | Assert.True(subscriptionResponse.IsSuccessStatusCode, $"Could not subscribe to hub: {subscriptionResponse}"); 117 | await Task.Delay(1000); 118 | 119 | // Act 120 | var result = await new HttpClient().GetStringAsync(subscriptionUrl); 121 | var subscriptions = JsonConvert.DeserializeObject(result); 122 | 123 | // Assert 124 | Assert.Single(subscriptions); 125 | 126 | // Check that all the passed values in subscription are as expected 127 | SubscriptionRequest returnedSubstription = subscriptions[0]; 128 | VerifiySubscription(subscription, returnedSubstription); 129 | } 130 | 131 | //[Fact] 132 | // TODO: There are some timing issues affecting this test. When unsubscribing the call is somehow receiving the first subscribe message 133 | // as well. 134 | public async Task ListingSubscriptions_AfterUnSubscribingFromHub_Test() 135 | { 136 | // Arrange 137 | var sessionId = "subscribe_id1"; 138 | var connectionId = "subscribe_client_connection_id_1"; 139 | var subscriptionUrl = $"http://localhost:{this.hubServerPort}/api/hub"; 140 | var topic = $"{subscriptionUrl}/{sessionId}"; 141 | var events = new[] { "some_event_1" }; 142 | var callback = $"http://localhost:{this.webSubClientServerPort}/callback/{connectionId}"; 143 | int leaseSeconds = 1201; 144 | var subscription1 = CreateNewSubscription(subscriptionUrl, topic, events, callback, leaseSeconds); 145 | var httpContent = subscription1.CreateHttpContent(); 146 | 147 | var clientTestResponse = await new HttpClient().GetAsync(callback); 148 | Assert.True(clientTestResponse.IsSuccessStatusCode, $"Could not connect to web sub client: {clientTestResponse}"); 149 | 150 | var subscriptionResponse = await new HttpClient().PostAsync(subscriptionUrl, httpContent); 151 | Assert.True(subscriptionResponse.IsSuccessStatusCode, $"Could not subscribe to hub: {subscriptionResponse}"); 152 | await Task.Delay(1000); 153 | 154 | // Act 155 | var result = await new HttpClient().GetStringAsync(subscriptionUrl); 156 | var subscriptions = JsonConvert.DeserializeObject(result); 157 | 158 | // Assert 159 | Assert.Single(subscriptions); 160 | // Check that all the passed values in subscription are as expected 161 | SubscriptionRequest returnedSubstription = subscriptions[0]; 162 | VerifiySubscription(subscription1, returnedSubstription); 163 | 164 | // Arrange 165 | sessionId = "unsubscribe_id"; 166 | connectionId = "unsubscribe_client_connection_id"; 167 | subscriptionUrl = $"http://localhost:{this.hubServerPort}/api/hub"; 168 | topic = $"{subscriptionUrl}/{sessionId}"; 169 | events = new[] { "some_event" }; 170 | callback = $"http://localhost:{this.webSubClientServerPort}/callback/{connectionId}"; 171 | leaseSeconds = 1202; 172 | var subscription2 = CreateNewSubscription(subscriptionUrl, topic, events, callback, leaseSeconds); 173 | httpContent = subscription2.CreateHttpContent(); 174 | 175 | clientTestResponse = await new HttpClient().GetAsync(callback); 176 | Assert.True(clientTestResponse.IsSuccessStatusCode, $"Could not connect to web sub client: {clientTestResponse}"); 177 | 178 | subscriptionResponse = await new HttpClient().PostAsync(subscriptionUrl, httpContent); 179 | Assert.True(subscriptionResponse.IsSuccessStatusCode, $"Could not subscribe to hub: {subscriptionResponse}"); 180 | await Task.Delay(1000); 181 | 182 | // Act 183 | result = await new HttpClient().GetStringAsync(subscriptionUrl); 184 | subscriptions = JsonConvert.DeserializeObject(result); 185 | 186 | // Assert 187 | Assert.Equal(2, subscriptions.Length); 188 | 189 | // Check that all the passed values in both subscriptions are as expected 190 | returnedSubstription = subscriptions.FirstOrDefault(a => a.Topic.Equals(subscription1.Topic)); 191 | VerifiySubscription(subscription1, returnedSubstription); 192 | 193 | returnedSubstription = subscriptions.FirstOrDefault(a => a.Topic.Equals(subscription2.Topic)); 194 | VerifiySubscription(subscription2, returnedSubstription); 195 | 196 | //var unSubscription = CreateNewSubscription(subscriptionUrl, topic, events, callback, leaseSeconds); 197 | subscription2.Mode = SubscriptionMode.unsubscribe; 198 | httpContent = subscription2.CreateHttpContent(); 199 | 200 | clientTestResponse = await new HttpClient().GetAsync(callback); 201 | Assert.True(clientTestResponse.IsSuccessStatusCode, $"Could not connect to web sub client: {clientTestResponse}"); 202 | 203 | subscriptionResponse = await new HttpClient().PostAsync(subscriptionUrl, httpContent); 204 | Assert.True(subscriptionResponse.IsSuccessStatusCode, $"Could not subscribe to hub: {subscriptionResponse}"); 205 | await Task.Delay(1000); 206 | 207 | // Act 208 | result = await new HttpClient().GetStringAsync(subscriptionUrl); 209 | subscriptions = JsonConvert.DeserializeObject(result); 210 | 211 | // Assert 212 | Assert.Single(subscriptions); 213 | // Check that all the passed values in subscription are as expected 214 | returnedSubstription = subscriptions.FirstOrDefault(a => a.Topic.Equals(subscription1.Topic)); 215 | VerifiySubscription(subscription1, returnedSubstription); 216 | } 217 | 218 | public void Dispose() 219 | { 220 | Task.WaitAll( 221 | this.hubServer.StopAsync(), 222 | this.webSubClientServer.StopAsync()); 223 | this.hubServer.Dispose(); 224 | this.webSubClientServer.Dispose(); 225 | } 226 | 227 | private SubscriptionRequest CreateNewSubscription(string subscriptionUrl, string topic, string[] events, string callback, int leaseSeconds = 3600) 228 | { 229 | var rngCsp = new RNGCryptoServiceProvider(); 230 | var buffer = new byte[32]; 231 | rngCsp.GetBytes(buffer); 232 | var secret = BitConverter.ToString(buffer).Replace("-", ""); 233 | var subscription = new SubscriptionRequest() 234 | { 235 | Callback = callback, 236 | Events = events, 237 | Mode = Common.Model.SubscriptionMode.subscribe, 238 | Secret = secret, 239 | Lease_Seconds = leaseSeconds, 240 | Topic = topic 241 | }; 242 | subscription.HubDetails = new HubDetails() { HubUrl = subscriptionUrl }; 243 | 244 | return subscription; 245 | } 246 | } 247 | } 248 | 249 | -------------------------------------------------------------------------------- /Tests/NotificationTests.cs: -------------------------------------------------------------------------------- 1 |  2 | 3 | using FHIRcastSandbox.Model; 4 | using Newtonsoft.Json.Linq; 5 | using System; 6 | using Xunit; 7 | 8 | namespace FHIRcastSandbox 9 | { 10 | public class NotificationTests 11 | { 12 | #region Unit Tests 13 | [Fact] 14 | public void EqualNotifications_ConfirmEquality_Test() 15 | { 16 | Notification notification1 = CreateNotification(1); 17 | Notification notification2 = CreateNotification(1); 18 | notification2.Id = notification1.Id; 19 | 20 | Assert.True(notification1.Equals(notification2)); 21 | } 22 | 23 | [Fact] 24 | public void UnequalNotifications_ConfirmInequality_Test() 25 | { 26 | Notification notification1 = CreateNotification(1); 27 | Notification notification2 = CreateNotification(1); // Create notification uses a new GUID each time 28 | 29 | Assert.False(notification1.Equals(notification2)); 30 | } 31 | 32 | [Fact] 33 | public void Notification_SingleResource_ConvertToJSONString_Test() 34 | { 35 | Notification notification = CreateNotification(1); 36 | string jsonBody = notification.ToJson(); 37 | 38 | string error; 39 | 40 | Assert.True(ValidJson(jsonBody, out error), "Error validating JSON format: " + error); 41 | Assert.True(ValidTimestamp(jsonBody, out error), "Error validating timestamp: " + error); 42 | Assert.True(ValidId(jsonBody, out error), "Error validating id: " + error); 43 | Assert.True(ValidEventObject(jsonBody, out error), "Error validating event: " + error); 44 | } 45 | 46 | [Fact] 47 | public void Notification_MultipleResources_ConvertToJSONString_Test() 48 | { 49 | Notification notification = CreateNotification(2); 50 | string jsonBody = notification.ToJson(); 51 | 52 | string error; 53 | 54 | Assert.True(ValidJson(jsonBody, out error), "Error validating JSON format: " + error); 55 | Assert.True(ValidTimestamp(jsonBody, out error), "Error validating timestamp: " + error); 56 | Assert.True(ValidId(jsonBody, out error), "Error validating id: " + error); 57 | Assert.True(ValidEventObject(jsonBody, out error), "Error validating event: " + error); 58 | 59 | } 60 | 61 | [Fact] 62 | public void Notification_SingleResource_ConvertFromJSONString_Test() 63 | { 64 | Notification notification = CreateNotification(1); 65 | string json = notification.ToJson(); 66 | 67 | Notification notificationFromJson = Notification.FromJson(json); 68 | 69 | 70 | Assert.True(notification.Equals(notificationFromJson, true)); 71 | } 72 | 73 | #endregion 74 | 75 | 76 | 77 | private bool ValidJson(string jsonBody, out string error) 78 | { 79 | error = ""; 80 | jsonBody = jsonBody.Trim(); 81 | if ((jsonBody.StartsWith("{") && jsonBody.EndsWith("}")) || //For object 82 | (jsonBody.StartsWith("[") && jsonBody.EndsWith("]"))) //For array 83 | { 84 | try 85 | { 86 | var obj = JToken.Parse(jsonBody); 87 | return true; 88 | } 89 | catch (Exception ex) //some exception 90 | { 91 | error = ex.Message; 92 | return false; 93 | } 94 | } 95 | else 96 | { 97 | return false; 98 | } 99 | } 100 | 101 | #region Property Validator Functions 102 | private bool ValidTimestamp(string jsonBody, out string error) 103 | { 104 | error = ""; 105 | JObject notificationObject = JObject.Parse(jsonBody); 106 | 107 | // Body SHALL contain a timestamp 108 | if (!JObjectHasKeyWithValue(notificationObject, "timestamp")) 109 | { 110 | error = "json does not include timestamp property"; 111 | return false; 112 | } 113 | 114 | // Value needs to be a valid date time string 115 | DateTime time; 116 | if (!DateTime.TryParse(notificationObject.Property("timestamp").Value.ToString(), out time)) 117 | { 118 | error = "timestamp does not conform to ISO 8601 format"; 119 | return false; 120 | } 121 | 122 | return true; 123 | } 124 | 125 | private bool ValidId(string jsonBody, out string error) 126 | { 127 | error = ""; 128 | JObject notificationObject = JObject.Parse(jsonBody); 129 | 130 | // Body SHALL contain an id 131 | if (!JObjectHasKeyWithValue(notificationObject, "id")) 132 | { 133 | error = "json does not include id property"; 134 | return false; 135 | } 136 | 137 | return true; 138 | } 139 | 140 | private bool ValidEventObject(string jsonBody, out string error) 141 | { 142 | error = ""; 143 | JObject notificationObject = JObject.Parse(jsonBody); 144 | 145 | // Body SHALL contain an event 146 | if (!JObjectHasKeyWithValue(notificationObject, "event")) 147 | { 148 | error = "json does not include event property"; 149 | return false; 150 | } 151 | 152 | JObject eventObj = notificationObject["event"].ToObject(); 153 | 154 | if (!ValidHubTopic(eventObj, out error)) return false; 155 | if (!ValidHubEvent(eventObj, out error)) return false; 156 | if (!ValidContext(eventObj, out error)) return false; 157 | 158 | return true; 159 | } 160 | 161 | private bool ValidHubTopic(JObject eventObj, out string error) 162 | { 163 | error = ""; 164 | 165 | // event SHALL contain a hub.topic 166 | if (!JObjectHasKeyWithValue(eventObj, "hub.topic")) 167 | { 168 | error = "event does not include a valid hub.topic property"; 169 | return false; 170 | } 171 | 172 | // add any topic validation here if there is any 173 | 174 | return true; 175 | } 176 | 177 | private bool ValidHubEvent(JObject eventObj, out string error) 178 | { 179 | error = ""; 180 | 181 | // event SHALL contain a hub.event 182 | if (!JObjectHasKeyWithValue(eventObj, "hub.event")) 183 | { 184 | error = "event does not include a valid hub.event property"; 185 | return false; 186 | } 187 | 188 | // add any event validation here if there is any 189 | 190 | return true; 191 | } 192 | 193 | private bool ValidContext(JObject eventObj, out string error) 194 | { 195 | error = ""; 196 | 197 | // event SHALL contain a context 198 | if (!JObjectHasKeyWithValue(eventObj, "context")) 199 | { 200 | error = "event does not include a valid context property"; 201 | return false; 202 | } 203 | 204 | JToken context = eventObj["context"]; 205 | if (context.Type != JTokenType.Array) 206 | { 207 | error = "context is not an array type"; 208 | return false; 209 | } 210 | 211 | foreach (JObject resource in context.Children()) 212 | { 213 | if (!ValidResource(resource, out error)) return false; 214 | } 215 | 216 | return true; 217 | } 218 | 219 | private bool ValidResource(JObject resourceObj, out string error) 220 | { 221 | error = ""; 222 | 223 | // resource SHALL have a key property 224 | if (!JObjectHasKeyWithValue(resourceObj, "key")) 225 | { 226 | error = "resource does not have a valid key property"; 227 | return false; 228 | } 229 | 230 | if (!JObjectHasKeyWithValue(resourceObj, "resource")) 231 | { 232 | error = "resource does not have a valid resource property"; 233 | return false; 234 | } 235 | 236 | try 237 | { 238 | string key = resourceObj["key"].ToString(); 239 | switch (key) 240 | { 241 | case "patient": 242 | Hl7.Fhir.Model.Patient patient = resourceObj.ToObject(); 243 | break; 244 | case "imagingstudy": 245 | Hl7.Fhir.Model.ImagingStudy study = resourceObj.ToObject(); 246 | break; 247 | case "encounter": 248 | break; 249 | } 250 | } 251 | catch (Exception ex) 252 | { 253 | error = "Error parsing resource object: " + ex.Message; 254 | return false; 255 | } 256 | 257 | return true; 258 | } 259 | 260 | private bool JObjectHasKeyWithValue(JObject jObject, string key) 261 | { 262 | if (!jObject.ContainsKey(key)) return false; 263 | if (jObject[key].ToString() == String.Empty) return false; 264 | 265 | return true; 266 | } 267 | 268 | #endregion 269 | 270 | private Notification CreateNotification(int numberOfResources) 271 | { 272 | Notification notification = new Notification 273 | { 274 | Id = Guid.NewGuid().ToString(), 275 | Timestamp = DateTime.Now, 276 | }; 277 | 278 | Hl7.Fhir.Model.Resource[] resources = new Hl7.Fhir.Model.Resource[numberOfResources]; 279 | 280 | Hl7.Fhir.Model.Patient patient = new Hl7.Fhir.Model.Patient(); 281 | patient.Id = "abc1234"; 282 | resources[0] = patient; 283 | 284 | if (numberOfResources >= 2) 285 | { 286 | Hl7.Fhir.Model.ImagingStudy imagingStudy = new Hl7.Fhir.Model.ImagingStudy(); 287 | imagingStudy.Accession = new Hl7.Fhir.Model.Identifier("accession", "acc123"); 288 | resources[1] = imagingStudy; 289 | } 290 | 291 | if (numberOfResources >= 3) 292 | { 293 | Hl7.Fhir.Model.Encounter encounter = new Hl7.Fhir.Model.Encounter(); 294 | encounter.Id = "enc1234"; 295 | resources[2] = encounter; 296 | } 297 | 298 | NotificationEvent notificationEvent = new NotificationEvent() 299 | { 300 | Topic = "topic1", 301 | Event = "open-patient-chart", 302 | Context = resources 303 | }; 304 | 305 | notification.Event = notificationEvent; 306 | return notification; 307 | } 308 | 309 | private string SingleResourceNotificationJSONString() 310 | { 311 | return @"{ 312 | ""timestamp"": ""2018-01-08T01:37:05.14"", 313 | ""id"": ""q9v3jubddqt63n1"", 314 | ""event"": { 315 | ""hub.topic"": ""https://hub.example.com/7jaa86kgdudewiaq0wtu"", 316 | ""hub.event"": ""open-patient-chart"", 317 | ""context"": [ 318 | { 319 | ""key"": ""patient"", 320 | ""resource"": { 321 | ""resourceType"": ""Patient"", 322 | ""id"": ""ewUbXT9RWEbSj5wPEdgRaBw3"", 323 | ""identifier"": [ 324 | { 325 | ""type"": { 326 | ""coding"": [ 327 | { 328 | ""system"": ""http://terminology.hl7.org/CodeSystem/v2-0203"", 329 | ""value"": ""MR"", 330 | ""display"": ""Medication Record Number"" 331 | } 332 | ], 333 | ""text"": ""MRN"" 334 | } 335 | } 336 | ] 337 | } 338 | } 339 | ] 340 | } 341 | }"; 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /Tests/SubscriptionTests.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using Microsoft.AspNetCore; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.AspNetCore.TestHost; 5 | using Microsoft.Extensions.Configuration; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Net; 9 | using System.Net.Http; 10 | using System.Web; 11 | using Xunit; 12 | 13 | namespace FHIRcastSandbox 14 | { 15 | public class SubscriptionTests 16 | { 17 | private readonly TestServer _hubServer; 18 | private readonly HttpClient _hubClient; 19 | 20 | private readonly TestServer _webSubServer; 21 | private readonly HttpClient _webSubClient; 22 | 23 | public SubscriptionTests() 24 | { 25 | _hubServer = new TestServer(WebHost.CreateDefaultBuilder() 26 | .UseStartup() 27 | .UseEnvironment("Development")); 28 | _hubClient = _hubServer.CreateClient(); 29 | 30 | _webSubServer = new TestServer(WebHost.CreateDefaultBuilder() 31 | .UseStartup() 32 | .UseEnvironment("Development") 33 | .ConfigureAppConfiguration((_, config) => 34 | config.AddInMemoryCollection(new Dictionary { 35 | { "Settings:ValidateSubscriptionValidations", "False" }, 36 | { "Logging:LogLevel:Default", "Warning" }, 37 | }))); 38 | 39 | 40 | _webSubClient = _webSubServer.CreateClient(); 41 | } 42 | 43 | #region Model Tests 44 | [Fact] 45 | public void SubscriptionVerification_VerificationURI_ReturnsCorrectURI() 46 | { 47 | string callback = "https://testcallback/callback"; 48 | SubscriptionMode mode = SubscriptionMode.subscribe; 49 | string topic = "testTopic"; 50 | string secret = "secretCode"; 51 | string[] events = new string[] { "open-patient", "close-patient" }; 52 | int lease_seconds = 3600; 53 | 54 | SubscriptionRequest subscriptionRequest = new SubscriptionRequest() 55 | { 56 | Callback = callback, 57 | Mode = mode, 58 | Topic = topic, 59 | Secret = secret, 60 | Events = events, 61 | Lease_Seconds = lease_seconds 62 | }; 63 | 64 | SubscriptionVerification subscriptionVerification = SubscriptionVerification.CreateSubscriptionVerification(subscriptionRequest); 65 | 66 | string verificationURL = $"{callback}?hub.mode={HttpUtility.UrlEncode(mode.ToString())}" 67 | + $"&hub.topic={HttpUtility.UrlEncode(topic)}&hub.events={HttpUtility.UrlEncode(string.Join(",", events))}" 68 | + $"&hub.lease_seconds={HttpUtility.UrlEncode(lease_seconds.ToString())}&hub.challenge={HttpUtility.UrlEncode(subscriptionVerification.Challenge)}"; 69 | 70 | Assert.Equal(verificationURL, subscriptionVerification.VerificationURI().ToString()); 71 | } 72 | 73 | [Fact] 74 | public void EqualSubscriptionRequests_Equals_ReturnsTrue() 75 | { 76 | SubscriptionRequest request1 = new SubscriptionRequest() 77 | { 78 | Callback = "callback", 79 | Events = new string[] 80 | { 81 | "event1", 82 | "event2" 83 | }, 84 | Lease_Seconds = 3600, 85 | Mode = SubscriptionMode.subscribe, 86 | Secret = "secret", 87 | Topic = "topic" 88 | }; 89 | SubscriptionRequest request2 = new SubscriptionRequest() 90 | { 91 | Callback = "callback", 92 | Events = new string[] 93 | { 94 | "event1", 95 | "event2" 96 | }, 97 | Lease_Seconds = 3600, 98 | Mode = SubscriptionMode.subscribe, 99 | Secret = "secret", 100 | Topic = "topic" 101 | }; 102 | 103 | Assert.True(request1.Equals(request2)); 104 | } 105 | 106 | [Fact] 107 | public void UnequalSubscriptionRequests_Equals_ReturnsFalse() 108 | { 109 | SubscriptionRequest request1 = new SubscriptionRequest() 110 | { 111 | Callback = "callback", 112 | Events = new string[] 113 | { 114 | "event1", 115 | "event2" 116 | }, 117 | Lease_Seconds = 3600, 118 | Mode = SubscriptionMode.subscribe, 119 | Secret = "secret", 120 | Topic = "topic" 121 | }; 122 | SubscriptionRequest request2 = new SubscriptionRequest() 123 | { 124 | Callback = "invalid callback", 125 | Events = new string[] 126 | { 127 | "event1", 128 | "event2" 129 | }, 130 | Lease_Seconds = 3600, 131 | Mode = SubscriptionMode.subscribe, 132 | Secret = "secret", 133 | Topic = "topic" 134 | }; 135 | 136 | Assert.False(request1.Equals(request2)); // Unequal callback 137 | 138 | request2.Callback = request1.Callback; 139 | request2.Events = new string[] { "event1", "event2", "event3" }; 140 | 141 | Assert.False(request1.Equals(request2)); // Unequal events 142 | 143 | request2.Events = request1.Events; 144 | request2.Topic = "invalid topic"; 145 | 146 | Assert.False(request1.Equals(request2)); // Unequal topic 147 | } 148 | 149 | [Fact] 150 | public void EqualSubscriptionVerifications_Equals_ReturnsTrue() 151 | { 152 | SubscriptionVerification verification1 = new SubscriptionVerification() 153 | { 154 | Challenge = "challenge", 155 | Events = new string[] 156 | { 157 | "event1", 158 | "event2" 159 | }, 160 | Lease_Seconds = 3600, 161 | Mode = SubscriptionMode.subscribe, 162 | Topic = "topic" 163 | }; 164 | SubscriptionVerification verification2 = new SubscriptionVerification() 165 | { 166 | Challenge = "challenge", 167 | Events = new string[] 168 | { 169 | "event1", 170 | "event2" 171 | }, 172 | Lease_Seconds = 3600, 173 | Mode = SubscriptionMode.subscribe, 174 | Topic = "topic" 175 | }; 176 | 177 | Assert.True(verification1.Equals(verification2)); 178 | } 179 | 180 | [Fact] 181 | public void UnequalSubscriptionVerifications_Equals_ReturnsFalse() 182 | { 183 | SubscriptionVerification verification1 = new SubscriptionVerification() 184 | { 185 | Challenge = "challenge", 186 | Events = new string[] 187 | { 188 | "event1", 189 | "event2" 190 | }, 191 | Lease_Seconds = 3600, 192 | Mode = SubscriptionMode.subscribe, 193 | Topic = "topic" 194 | }; 195 | SubscriptionVerification verification2 = new SubscriptionVerification() 196 | { 197 | Challenge = "challenge", 198 | Events = new string[] 199 | { 200 | "event1", 201 | "event2", 202 | "event3" 203 | }, 204 | Lease_Seconds = 3600, 205 | Mode = SubscriptionMode.subscribe, 206 | Topic = "topic" 207 | }; 208 | 209 | Assert.False(verification1.Equals(verification2)); 210 | 211 | verification2.Events = verification1.Events; 212 | verification2.Topic = "invalid topic"; 213 | 214 | Assert.False(verification1.Equals(verification2)); 215 | } 216 | 217 | [Fact] 218 | public void SubscriptionRequest_SubscriptionVerification_EqualCases_ReturnTrue() 219 | { 220 | // There are certain cases where we will have a SubscriptionVerification and need to match 221 | // it to an existing SubscriptionRequest (see Subscriptions class in WebSubClient). These 222 | // tests verify those cases 223 | 224 | SubscriptionRequest request = new SubscriptionRequest() 225 | { 226 | Callback = "callback", 227 | Events = new string[] 228 | { 229 | "event1", 230 | "event2" 231 | }, 232 | Lease_Seconds = 3600, 233 | Mode = SubscriptionMode.subscribe, 234 | Secret = "secret", 235 | Topic = "topic" 236 | }; 237 | 238 | SubscriptionVerification verification = new SubscriptionVerification() 239 | { 240 | Challenge = "challenge", 241 | Events = new string[] 242 | { 243 | "event1", 244 | "event2" 245 | }, 246 | Lease_Seconds = 3600, 247 | Mode = SubscriptionMode.subscribe, 248 | Topic = "topic" 249 | }; 250 | 251 | Assert.True(request.Equals(verification)); 252 | Assert.True(verification.Equals(request)); 253 | } 254 | #endregion 255 | 256 | #region HTTP Tests 257 | [Fact] 258 | public async void Post_HubController_FromForm_ValidData_SuccessResponse() 259 | { 260 | Dictionary formData = new Dictionary 261 | { 262 | {"hub.callback", "testcallback" }, 263 | {"hub.mode", "subscribe" }, 264 | {"hub.topic", "testtopic" }, 265 | {"hub.events", "patient-open,patient-close" }, 266 | {"hub.secret", "testsecret" }, 267 | {"hub.lease_seconds", "3600" } 268 | }; 269 | 270 | var response = await _hubClient.PostAsync("api/hub", new FormUrlEncodedContent(formData)); 271 | 272 | Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); 273 | } 274 | 275 | [Fact] 276 | public async void Post_HubController_FromForm_InvalidData_BadResultResponse() 277 | { 278 | Dictionary formData = new Dictionary 279 | { 280 | //{"hub.callback", "testcallback" }, 281 | {"hub.mode", "subscribe" }, 282 | {"hub.topic", "testtopic" }, 283 | {"hub.events", "patient-open,patient-close" }, 284 | {"hub.secret", "testsecret" }, 285 | {"hub.lease_seconds", "3600" } 286 | }; 287 | 288 | var response = await _hubClient.PostAsync("api/hub", new FormUrlEncodedContent(formData)); 289 | 290 | Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 291 | } 292 | 293 | [Fact] 294 | public async void Get_CallbackController_Verification_ReturnsChallenge() 295 | { 296 | SubscriptionRequest subscription = new SubscriptionRequest() 297 | { 298 | Callback = "http://localhost:5001/callback/testTopic", 299 | Mode = SubscriptionMode.subscribe, 300 | Topic = "testTopic", 301 | Events = new string[] 302 | { 303 | "patient-open", 304 | "patient-close" 305 | }, 306 | Secret = "testSecret", 307 | Lease_Seconds = 3600 308 | }; 309 | SubscriptionVerification verification = SubscriptionVerification.CreateSubscriptionVerification(subscription, false); 310 | 311 | Uri verificationUri = verification.VerificationURI(); 312 | 313 | var response = await _webSubClient.GetAsync(verificationUri); 314 | 315 | Assert.True(response.IsSuccessStatusCode); 316 | } 317 | #endregion 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /Tests/Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.1 5 | 6 | false 7 | 8 | FHIRcastSandbox 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /WebSubClient/Controllers/CallbackController.cs: -------------------------------------------------------------------------------- 1 | using FHIRcastSandbox.Hubs; 2 | using FHIRcastSandbox.Model; 3 | using FHIRcastSandbox.WebSubClient.Rules; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.Logging; 7 | using System.IO; 8 | using System.Text; 9 | using System.Threading.Tasks; 10 | 11 | namespace FHIRcastSandbox.WebSubClient.Controllers 12 | { 13 | [Route("callback")] 14 | public class CallbackController : Controller 15 | { 16 | private readonly IConfiguration _config; 17 | private readonly WebSubClientHub _clientHub; 18 | private readonly ILogger _logger; 19 | private readonly Subscriptions _subscriptions; 20 | 21 | public CallbackController(IConfiguration config, WebSubClientHub hub, ILogger logger, Subscriptions subscriptions) 22 | { 23 | _config = config; 24 | _clientHub = hub; 25 | _logger = logger; 26 | _subscriptions = subscriptions; 27 | } 28 | 29 | /// 30 | /// Called by hub we sent a subscription request to. They are attempting to verify the subscription. 31 | /// If the subscription matches one that we have sent out previously that hasn't been verified yet 32 | /// then return their challenge value, otherwise return a NotFound error response. 33 | /// 34 | /// SignalR clientId used in the callback URL 35 | /// Hub's verification response to our subscription attempt 36 | /// challenge parameter if subscription is verified 37 | [HttpGet("{clientId}")] 38 | public async Task SubscriptionVerification(string clientId, [Bind(Prefix = "hub")][FromQuery] Common.Model.SubscriptionVerification verification) 39 | { 40 | if (!_config.GetValue("Settings:ValidateSubscriptionValidations", true)) 41 | { 42 | return Content(verification.Challenge); 43 | } 44 | 45 | _logger.LogDebug($"Recieved subscription verification for {clientId}: {verification.ToString()}"); 46 | 47 | if (verification.Mode == Common.Model.SubscriptionMode.denied) 48 | { 49 | // SHALL respond with a 200 code 50 | return Content(""); 51 | } 52 | 53 | if (_subscriptions.VerifiedSubscription(clientId, verification)) 54 | { 55 | _logger.LogDebug($"Found matching subscription, echoing challenge"); 56 | // have a matching subscription and activated it, 57 | // inform client so it can update UI and echo challenge back to app 58 | await _clientHub.SubscriptionsChanged(clientId); 59 | return Content(verification.Challenge); 60 | } 61 | else 62 | { 63 | _logger.LogError($"Did not find matching subscription, not verifying subscription"); 64 | // we don't have a matching pending subscription so don't verify 65 | return Content(""); 66 | } 67 | } 68 | 69 | /// 70 | /// A client we subscribed to is posting a notification to us 71 | /// 72 | /// SignalR clientId used in the callback URL 73 | /// The notification. 74 | /// 75 | [HttpPost("{clientId}")] 76 | public async Task Notification(string clientId) 77 | { //, [FromBody] Notification notification) { 78 | Notification notification; 79 | using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8)) 80 | { 81 | notification = Model.Notification.FromJson(reader.ReadToEnd()); 82 | } 83 | 84 | _logger.LogDebug($"Received notification for {clientId}: {notification.ToString()}"); 85 | 86 | // If we have a matching subscription then notify client to update UI and respond with a success code 87 | if (_subscriptions.HasMatchingSubscription(clientId, notification)) 88 | { 89 | await _clientHub.ReceivedNotification(clientId, notification); 90 | return this.Ok(); 91 | } 92 | else 93 | { 94 | return BadRequest(); 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /WebSubClient/Controllers/WebSubClientController.cs: -------------------------------------------------------------------------------- 1 | using FHIRcastSandbox.Model; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace FHIRcastSandbox.Controllers 6 | { 7 | [Route("")] 8 | public class HomeController : Controller 9 | { 10 | public IActionResult Index() 11 | { 12 | return this.RedirectToActionPermanent( 13 | nameof(WebSubClientController.Get), 14 | nameof(WebSubClientController).Replace("Controller", "")); 15 | } 16 | } 17 | 18 | [Route("client")] 19 | public class WebSubClientController : Controller 20 | { 21 | 22 | private readonly ILogger logger; 23 | 24 | public WebSubClientController(ILogger logger) 25 | { 26 | this.logger = logger; 27 | } 28 | 29 | [HttpGet] 30 | public IActionResult Get() => this.View(nameof(WebSubClientController).Replace("Controller", ""), new ClientModel()); 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /WebSubClient/Hubs/IWebSubClient.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Model; 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | 6 | namespace FHIRcastSandbox.Hubs 7 | { 8 | /// 9 | /// Interface to create a strongly typed SignalR hub between WebSubClientHub and the javascript client 10 | /// 11 | public interface IWebSubClient 12 | { 13 | Task ReceivedNotification(Notification notification); 14 | 15 | Task SubscriptionsChanged(List subscriptions); 16 | Task SubscriberAdded(SubscriptionRequest subscriber); 17 | Task SubscriberRemoved(SubscriptionRequest subscriber); 18 | 19 | Task AlertMessage(string message); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /WebSubClient/Hubs/InternalHubClient.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using Microsoft.AspNetCore.SignalR.Client; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.Logging; 5 | using System; 6 | using System.Threading.Tasks; 7 | 8 | namespace FHIRcastSandbox.WebSubClient.Hubs 9 | { 10 | /// 11 | /// Client object in InternalHub SignalR communcation. Handles talking to and receiving messages from the Hub project. 12 | /// These messages include new subscriptions to this client (topic) or this client sending out a notification to a 13 | /// subscriber. 14 | /// 15 | public class InternalHubClient 16 | { 17 | #region Member Variables 18 | private readonly ILogger logger; 19 | private readonly IConfiguration config; 20 | private HubConnection hubConnection; 21 | #endregion 22 | 23 | #region Events 24 | public delegate void SubscriberAddedEventHandler(object sender, SubscriptionRequest subscription); 25 | public delegate void SubscriberRemovedEventHandler(object sender, SubscriptionRequest subscription); 26 | 27 | /// 28 | /// Event used to inform the WebSubClientHub of a new subscriber since it has the connection to the js client. 29 | /// Couldn't get a direct reference to WebSubClientHub because it created a circular dependency. 30 | /// 31 | public event SubscriberAddedEventHandler SubscriberAdded; 32 | public event SubscriberRemovedEventHandler SubscriberRemoved; 33 | public event EventHandler Error; 34 | 35 | private void RaiseError(string errorMessage) 36 | { 37 | logger.LogError(errorMessage); 38 | EventHandler handler = Error; 39 | handler?.Invoke(this, errorMessage); 40 | } 41 | 42 | private void RaiseAddSubscriber(SubscriptionRequest subscription) 43 | { 44 | logger.LogDebug($"Subscriber added notification from internal hub: {subscription.ToString()}"); 45 | SubscriberAddedEventHandler handler = SubscriberAdded; 46 | handler?.Invoke(this, subscription); 47 | } 48 | 49 | private void RaiseRemoveSubscriber(SubscriptionRequest subscription) 50 | { 51 | logger.LogDebug($"Subscriber removed notification from internal hub: {subscription.ToString()}"); 52 | SubscriberRemovedEventHandler handler = SubscriberRemoved; 53 | handler?.Invoke(this, subscription); 54 | } 55 | #endregion 56 | 57 | #region Initialization 58 | public InternalHubClient(ILogger logger, IConfiguration config) 59 | { 60 | this.logger = logger; 61 | this.config = config; 62 | 63 | CreateInternalHubConnection(); 64 | } 65 | 66 | /// 67 | /// Initiate SignalR connection with InternalHub 68 | /// 69 | private async void CreateInternalHubConnection() 70 | { 71 | string hubBaseURL = config.GetValue("Settings:HubBaseURL", "localhost"); 72 | int hubPort = config.GetValue("Settings:HubPort", 5000); 73 | 74 | hubConnection = new HubConnectionBuilder() 75 | .WithUrl($"http://{hubBaseURL}:{hubPort}/internalhub") 76 | .Build(); 77 | 78 | // Believe this automatically tries to reconnect. 79 | // ASP.NET Core SignalR documentation 80 | hubConnection.Closed += async (error) => 81 | { 82 | await Task.Delay(new Random().Next(0, 5) * 1000); 83 | await hubConnection.StartAsync(); 84 | }; 85 | 86 | // Add method handlers 87 | hubConnection.On("AddSubscriber", AddSubscriber); 88 | hubConnection.On("RemoveSubscriber", RemoveSubscriber); 89 | //hubConnection.On("RemoveSubscriber", RemoveSubscriber); //TODO: Implement this interaction 90 | 91 | try 92 | { 93 | await hubConnection.StartAsync(); 94 | logger.LogDebug("Started connection to internalhub"); 95 | } 96 | catch (Exception ex) 97 | { 98 | string errorMessage = $"Error connecting to InternalHub: {ex.Message}"; 99 | logger.LogError(errorMessage); 100 | RaiseError(errorMessage); 101 | } 102 | } 103 | #endregion 104 | 105 | #region Calls from WebSubHub 106 | public async void RegisterTopic(string topic) 107 | { 108 | CreateInternalHubConnection(); 109 | await RegisterTopicInternal(topic); 110 | } 111 | #endregion 112 | 113 | #region Calls from Internal Hub 114 | private void AddSubscriber(SubscriptionRequest subscription) 115 | { 116 | RaiseAddSubscriber(subscription); 117 | } 118 | private void RemoveSubscriber(SubscriptionRequest subscription) 119 | { 120 | RaiseRemoveSubscriber(subscription); 121 | } 122 | #endregion 123 | 124 | #region Calls to Internal Hub 125 | private async Task RegisterTopicInternal(string topic) 126 | { 127 | logger.LogDebug($"Registering {topic} with internal hub"); 128 | await hubConnection.InvokeAsync("RegisterTopic", topic); 129 | } 130 | #endregion 131 | 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /WebSubClient/Hubs/WebSubClientHub.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Model; 3 | using FHIRcastSandbox.WebSubClient.Hubs; 4 | using FHIRcastSandbox.WebSubClient.Rules; 5 | using Microsoft.AspNetCore.SignalR; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Logging; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Net.Http; 11 | using System.Security.Cryptography; 12 | using System.Text; 13 | using System.Threading.Tasks; 14 | 15 | namespace FHIRcastSandbox.Hubs 16 | { 17 | /// 18 | /// This is a SignalR hub for the js client, not to be confused with a FHIRcast hub. 19 | /// 20 | /// 21 | public class WebSubClientHub : Hub 22 | { 23 | private readonly ILogger logger; 24 | private readonly Subscriptions _subscriptions; 25 | private readonly IConfiguration config; 26 | private readonly IHubContext webSubClientHubContext; 27 | 28 | private readonly InternalHubClient internalHubClient; 29 | 30 | public WebSubClientHub(ILogger logger, IConfiguration config, IHubContext hubContext, InternalHubClient internalHubClient, Subscriptions subscriptions) 31 | { 32 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 33 | this.config = config; 34 | webSubClientHubContext = hubContext; 35 | this.internalHubClient = internalHubClient; 36 | 37 | this.internalHubClient.SubscriberRemoved += InternalHubClient_SubscriberRemoved; 38 | this.internalHubClient.SubscriberAdded += InternalHubClient_SubscriberAdded; 39 | _subscriptions = subscriptions; 40 | } 41 | 42 | private async void InternalHubClient_SubscriberAdded(object sender, SubscriptionRequest subscription) 43 | { 44 | await AddSubscriber(Context.ConnectionId, subscription); 45 | } 46 | 47 | private async void InternalHubClient_SubscriberRemoved(object sender, SubscriptionRequest subscription) 48 | { 49 | await RemoveSubscriber(Context.ConnectionId, subscription); 50 | } 51 | 52 | /// 53 | /// Client is attempting to subscribe to another app 54 | /// 55 | /// 56 | /// 57 | /// 58 | /// 59 | /// 60 | public async Task Subscribe(string subscriptionUrl, string topic, string events, string[] httpHeaders) 61 | { 62 | if (string.IsNullOrEmpty(subscriptionUrl)) 63 | { 64 | var hubBaseURL = config.GetValue("Settings:HubBaseURL", "localhost"); 65 | var hubPort = config.GetValue("Settings:HubPort", 5000); 66 | subscriptionUrl = new UriBuilder("http", hubBaseURL, hubPort, "/api/hub").Uri.ToString(); 67 | } 68 | 69 | string clientId = Context.ConnectionId; 70 | 71 | var rngCsp = new RNGCryptoServiceProvider(); 72 | var buffer = new byte[64]; 73 | rngCsp.GetBytes(buffer); 74 | var secret = Convert.ToBase64String(buffer); 75 | var clientBaseURL = config.GetValue("Settings:ClientBaseURL", "localhost"); 76 | var clientPort = config.GetValue("Settings:ClientPort", 5001); 77 | 78 | var callbackUri = new UriBuilder( 79 | "http", 80 | clientBaseURL, 81 | clientPort, 82 | $"/callback/{clientId}"); 83 | 84 | SubscriptionRequest subscription = new SubscriptionRequest() 85 | { 86 | Callback = callbackUri.Uri.OriginalString, 87 | Mode = SubscriptionMode.subscribe, 88 | Topic = topic, 89 | Secret = secret, 90 | Events = events.Split(",", StringSplitOptions.RemoveEmptyEntries), 91 | Lease_Seconds = 3600, 92 | HubDetails = new HubDetails() 93 | { 94 | HubUrl = subscriptionUrl, 95 | HttpHeaders = httpHeaders 96 | } 97 | }; 98 | 99 | if (!await PendAndPostSubscription(clientId, subscription)) 100 | { 101 | // I don't know do something 102 | } 103 | } 104 | 105 | public async Task Unsubscribe(string topic) 106 | { 107 | string clientId = Context.ConnectionId; 108 | 109 | SubscriptionRequest subscription; 110 | if (!_subscriptions.GetClientSubscription(clientId, topic, out subscription)) 111 | { 112 | return; 113 | } 114 | 115 | logger.LogDebug($"Unsubscribing subscription for {clientId}: {subscription}"); 116 | 117 | subscription.Mode = SubscriptionMode.unsubscribe; 118 | 119 | if (!await PendAndPostSubscription(clientId, subscription)) 120 | { 121 | // I don't know do something 122 | return; 123 | } 124 | } 125 | 126 | /// 127 | /// Posts the subscription request to its associated Hub. This is used for both new subscriptions 128 | /// as well as unsubscribing 129 | /// 130 | /// 131 | /// True if the Hub returned a success code, otherwise false 132 | private async Task PendAndPostSubscription(string clientId, SubscriptionRequest subscriptionRequest) 133 | { 134 | _subscriptions.AddPendingSubscription(clientId, subscriptionRequest); 135 | 136 | HttpClient client = new HttpClient(); 137 | 138 | foreach (string header in subscriptionRequest.HubDetails.HttpHeaders) 139 | { 140 | string[] split = header.Split(":"); 141 | client.DefaultRequestHeaders.Add(split[0], split[1]); 142 | } 143 | 144 | HttpResponseMessage response = await client.PostAsync(subscriptionRequest.HubDetails.HubUrl, subscriptionRequest.BuildPostHttpContent()); 145 | 146 | if (!response.IsSuccessStatusCode) 147 | { 148 | _subscriptions.RemovePendingSubscription(clientId, subscriptionRequest); 149 | } 150 | //else 151 | //{ 152 | // await SubscriptionsChanged(clientId); 153 | //} 154 | 155 | return response.IsSuccessStatusCode; 156 | } 157 | 158 | /// 159 | /// Recieved an update from our client, send that notification to the hub (HubController -> Notify) 160 | /// for it to send out to the awaiting subscriber 161 | /// 162 | /// topicId 163 | /// event that occurred 164 | /// contextual information sent down from client 165 | /// 166 | public async Task Update(string topic, string eventName, ClientModel model) 167 | { 168 | HttpClient httpClient = new HttpClient(); 169 | 170 | // Build Notification object 171 | Notification notification = new Notification 172 | { 173 | Id = Guid.NewGuid().ToString(), 174 | Timestamp = DateTime.Now, 175 | }; 176 | 177 | List resources = new List(); 178 | 179 | Hl7.Fhir.Model.Patient patient = new Hl7.Fhir.Model.Patient(); 180 | patient.Id = model.PatientID; 181 | if (patient.Id != "") 182 | { 183 | resources.Add(patient); 184 | } 185 | 186 | Hl7.Fhir.Model.ImagingStudy imagingStudy = new Hl7.Fhir.Model.ImagingStudy(); 187 | imagingStudy.Id = model.AccessionNumber; //This probably isn't exactly right, but is useful for testing 188 | //imagingStudy.Accession = new Hl7.Fhir.Model.Identifier("accession", model.AccessionNumber); 189 | if (imagingStudy.Id != "") 190 | { 191 | resources.Add(imagingStudy); 192 | } 193 | 194 | NotificationEvent notificationEvent = new NotificationEvent() 195 | { 196 | Topic = topic, 197 | Event = eventName, 198 | Context = resources.ToArray() 199 | }; 200 | notification.Event = notificationEvent; 201 | 202 | // Build hub url to send notification to 203 | var hubBaseURL = this.config.GetValue("Settings:HubBaseURL", "localhost"); 204 | var hubPort = this.config.GetValue("Settings:HubPort", 5000); 205 | string subscriptionUrl = new UriBuilder("http", hubBaseURL, hubPort, "/api/hub").Uri.ToString(); 206 | 207 | // Send notification and await response 208 | logger.LogDebug($"Sending notification to {subscriptionUrl}/{topic}: {notification.ToString()}"); 209 | var response = await httpClient.PostAsync($"{subscriptionUrl}/{topic}", new StringContent(notification.ToJson(), Encoding.UTF8, "application/json")); 210 | response.EnsureSuccessStatusCode(); 211 | } 212 | 213 | public string GetTopic() 214 | { 215 | logger.LogDebug($"Sending topic {this.Context.ConnectionId} up to client"); 216 | internalHubClient.RegisterTopic(Context.ConnectionId); 217 | return Context.ConnectionId; 218 | } 219 | 220 | #region Calls To Client 221 | public async Task ReceivedNotification(string connectionId, Notification notification) 222 | { 223 | logger.LogDebug($"ReceivedNotification for {connectionId}: {notification.ToString()}"); 224 | await webSubClientHubContext.Clients.Client(connectionId).ReceivedNotification(notification); 225 | } 226 | 227 | public async Task AddSubscriber(string connectionId, SubscriptionRequest subscription) 228 | { 229 | logger.LogDebug($"Adding subscriber for {connectionId}: {subscription.ToString()}"); 230 | await webSubClientHubContext.Clients.Client(connectionId).SubscriberAdded(subscription); 231 | } 232 | 233 | public async Task RemoveSubscriber(string connectionId, SubscriptionRequest subscription) 234 | { 235 | logger.LogDebug($"Removing subscriber for {connectionId}: {subscription.ToString()}"); 236 | await webSubClientHubContext.Clients.Client(connectionId).SubscriberRemoved(subscription); 237 | } 238 | 239 | public async Task SubscriptionsChanged(string clientId) 240 | { 241 | List subscriptions = _subscriptions.ClientsSubscriptions(clientId); 242 | logger.LogDebug($"Subscriptions changed for {clientId}. New list: {subscriptions}"); 243 | await webSubClientHubContext.Clients.Client(clientId).SubscriptionsChanged(_subscriptions.ClientsSubscriptions(clientId)); 244 | } 245 | 246 | public async Task AlertMessage(string connectionId, string message) 247 | { 248 | logger.LogDebug($"Alerting {connectionId}: {message}"); 249 | await webSubClientHubContext.Clients.Client(connectionId).AlertMessage(message); 250 | } 251 | #endregion 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /WebSubClient/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Logging; 10 | using NLog.Web; 11 | 12 | namespace FHIRcastSandbox.WebSubClient 13 | { 14 | public class Program 15 | { 16 | public static void Main(string[] args) { 17 | BuildWebHost(args).Run(); 18 | } 19 | 20 | public static IWebHostBuilder CreateWebHostBuilder(params string[] args) { 21 | return WebHost.CreateDefaultBuilder(args) 22 | .UseStartup() 23 | .UseNLog(); 24 | } 25 | 26 | public static IWebHost BuildWebHost(string[] args) => CreateWebHostBuilder(args).Build(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /WebSubClient/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "WebSubClient": { 4 | "commandName": "Project", 5 | "environmentVariables": { 6 | "ASPNETCORE_ENVIRONMENT": "Development" 7 | }, 8 | "ssl": false, 9 | "applicationUrl": "http://localhost:5001/" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /WebSubClient/README.md: -------------------------------------------------------------------------------- 1 | # WebSubClient 2 | 3 | This project serves to exemplify how a WebSub client could be built towards a FHIRcast Hub. The client consists of a server component and a web UI, but only the server component communicates directly with the Hub. The client and the server component communicates using proprietary means outside the FHIRcast standard. In order to have a user-facing client communicate with a FHIRcast Hub directly, see the [WebSocket addition to the standard][fhircast-websocket] (TODO: Add correct link here). 4 | 5 | ## Usage 6 | 7 | Assuming your current directly is the same as this file, you can run the WebSub client using: 8 | 9 | ```sh 10 | $ dotnet run 11 | ``` 12 | 13 | You can then go to http://localhost:5001 and use the web application to notify and subscribe to FHIRcast hubs. 14 | 15 | fhircast-websocket: http://fhircast.org 16 | -------------------------------------------------------------------------------- /WebSubClient/Rules/Subscriptions.cs: -------------------------------------------------------------------------------- 1 | using Common.Model; 2 | using FHIRcastSandbox.Model; 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | 8 | namespace FHIRcastSandbox.WebSubClient.Rules 9 | { 10 | public class Subscriptions 11 | { 12 | //Client's subscriptions 13 | private readonly ConcurrentDictionary> _activeSubscriptions = new ConcurrentDictionary>(); 14 | private readonly ConcurrentDictionary> _pendingSubscriptions = new ConcurrentDictionary>(); 15 | 16 | private readonly ConcurrentDictionary> _subscriptionsToClient = new ConcurrentDictionary>(); 17 | 18 | #region Client's Subscriptions 19 | /// 20 | /// This will be called by our client hub when we create a subscription request and post it to the external hub 21 | /// 22 | /// 23 | /// 24 | public void AddPendingSubscription(string clientId, SubscriptionRequest subscription) 25 | { 26 | ValidateClientIDInDictionary(clientId, _pendingSubscriptions); 27 | _pendingSubscriptions[clientId].Add(subscription); 28 | } 29 | 30 | public void RemovePendingSubscription(string clientId, SubscriptionRequest subscription) 31 | { 32 | ValidateClientIDInDictionary(clientId, _pendingSubscriptions); 33 | try 34 | { 35 | _pendingSubscriptions[clientId].Remove(subscription); 36 | } 37 | catch (Exception) 38 | { 39 | return; 40 | } 41 | } 42 | 43 | /// 44 | /// This will be called by our callback controller when we receive a verification from the external app we subscribed to 45 | /// Occurs for new subscriptions, updating subscrtiptions, or unsubscribing. If unsubscribing then we will remove it from the dictionary. 46 | /// 47 | /// 48 | /// 49 | /// 50 | /// True if we had a matching pending subscription and can verify the subscription 51 | /// False if we don't have a matching subscription and we shouldn't verify the subscription 52 | /// 53 | public bool VerifiedSubscription(string clientId, Common.Model.SubscriptionVerification subscription) 54 | { 55 | ValidateClientIDInDictionary(clientId, _pendingSubscriptions); 56 | ValidateClientIDInDictionary(clientId, _activeSubscriptions); 57 | 58 | SubscriptionRequest matchingRequest = new SubscriptionRequest(); 59 | bool foundMatch = false; 60 | foreach (SubscriptionRequest item in _pendingSubscriptions[clientId]) 61 | { 62 | if (item.Equals(subscription)) 63 | { 64 | matchingRequest = item; 65 | foundMatch = true; 66 | break; 67 | } 68 | } 69 | 70 | if (!foundMatch) 71 | { 72 | return false; 73 | } 74 | 75 | // If this same callback and topic exists then it will be overwritten in the external app so we should overwrite it here as well 76 | SubscriptionRequest activeSubscription; 77 | if (GetClientSubscription(clientId, subscription.Topic, out activeSubscription)) 78 | { 79 | _activeSubscriptions[clientId].Remove(activeSubscription); 80 | } 81 | 82 | // Only add if we are subscribed, not if we are denied or unsubscribed 83 | if (subscription.Mode == Common.Model.SubscriptionMode.subscribe) 84 | { 85 | _activeSubscriptions[clientId].Add(matchingRequest); 86 | } 87 | 88 | return true; 89 | } 90 | 91 | public List ClientsSubscriptions(string clientId) 92 | { 93 | ValidateClientIDInDictionary(clientId, _activeSubscriptions); 94 | return _activeSubscriptions[clientId]; 95 | } 96 | 97 | /// 98 | /// A callback/topic combination defines a unique subscription. If a hub receives that same combo then it SHALL overwrite its 99 | /// previous subscription. Therefore, since we use the clientId as the defining callback characteristic, we can find a unique 100 | /// subscription from just the clientId and topic. 101 | /// 102 | /// 103 | /// 104 | /// 105 | /// 106 | public bool GetClientSubscription(string clientId, string topic, out SubscriptionRequest subscriptionRequest) 107 | { 108 | ValidateClientIDInDictionary(clientId, _activeSubscriptions); 109 | List listRequests = _activeSubscriptions[clientId]; 110 | foreach (SubscriptionRequest subscription in listRequests) 111 | { 112 | // This assumes that we include our clientId in the callback (check WebSubClientHub) 113 | // Probably not a great long term assumption, but works for now. 114 | if (!subscription.Callback.Contains(clientId)) 115 | { 116 | continue; 117 | } 118 | 119 | if (!subscription.Topic.Equals(topic)) 120 | { 121 | continue; 122 | } 123 | 124 | subscriptionRequest = subscription; 125 | return true; 126 | } 127 | 128 | subscriptionRequest = null; 129 | return false; 130 | } 131 | 132 | public bool HasMatchingSubscription(string clientId, Notification notification) 133 | { 134 | ValidateClientIDInDictionary(clientId, _activeSubscriptions); 135 | List listRequests = _activeSubscriptions[clientId]; 136 | foreach (SubscriptionRequest subscription in listRequests) 137 | { 138 | if (SubscriptionMatchesNotification(subscription, notification)) 139 | { 140 | return true; 141 | } 142 | } 143 | return false; 144 | } 145 | #endregion 146 | 147 | #region Subscriptions To Client 148 | public void AddSubscriptionToClient(string clientId, SubscriptionRequest subscription) 149 | { 150 | //TODO 151 | } 152 | 153 | public void RemoveSubscriptionFromClient(string clientId, SubscriptionRequest subscription) 154 | { 155 | //TODO 156 | } 157 | 158 | public List SubscribersToNotify(string clientId, Notification notification) 159 | { 160 | if (!_subscriptionsToClient.ContainsKey(clientId)) 161 | { 162 | return new List(); 163 | } 164 | 165 | ConcurrentDictionary clientSubscribers = _subscriptionsToClient[clientId]; 166 | return clientSubscribers.Where(x => SubscriptionMatchesNotification(x.Value, notification)).Select(x => x.Value).ToList(); 167 | } 168 | #endregion 169 | 170 | #region Private Methods 171 | private bool SubscriptionMatchesNotification(SubscriptionRequest subscription, Notification notification) 172 | { 173 | return (subscription.Topic.Equals(notification.Event.Topic) && subscription.Events.Contains(notification.Event.Event)); 174 | } 175 | 176 | private void ValidateClientIDInDictionary(string clientId, ConcurrentDictionary> dictionary) 177 | { 178 | if (!dictionary.ContainsKey(clientId)) 179 | { 180 | List subscriptionRequests = new List(); 181 | dictionary.AddOrUpdate(clientId, subscriptionRequests, (key, oldValue) => subscriptionRequests); 182 | } 183 | } 184 | #endregion 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /WebSubClient/Startup.cs: -------------------------------------------------------------------------------- 1 | using FHIRcastSandbox.Hubs; 2 | using FHIRcastSandbox.WebSubClient.Hubs; 3 | using FHIRcastSandbox.WebSubClient.Rules; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace FHIRcastSandbox.WebSubClient 10 | { 11 | public class Startup { 12 | public Startup(IConfiguration configuration) { 13 | Configuration = configuration; 14 | } 15 | 16 | public IConfiguration Configuration { get; } 17 | 18 | // This method gets called by the runtime. Use this method to add services to the container. 19 | public void ConfigureServices(IServiceCollection services) { 20 | services.AddMvc(); 21 | services.AddSignalR(); 22 | services.AddSingleton(); 23 | services.AddSingleton(typeof(WebSubClientHub)); 24 | services.AddSingleton(typeof(InternalHubClient)); 25 | } 26 | 27 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 28 | public void Configure(IApplicationBuilder app, IHostingEnvironment env) { 29 | if (env.IsDevelopment()) { 30 | app.UseBrowserLink(); 31 | app.UseDeveloperExceptionPage(); 32 | } else { 33 | app.UseExceptionHandler("/Home/Error"); 34 | } 35 | 36 | app.UseStaticFiles(); 37 | 38 | //app.UseMvc(routes => { 39 | // routes.MapRoute( 40 | // name: "default", 41 | // template: "{controller=Home}/{action=Index}/{id?}"); 42 | //}); 43 | 44 | app.UseSignalR(routes => { 45 | routes.MapHub("/websubclienthub"); 46 | }); 47 | 48 | app.UseMvc(); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /WebSubClient/Views/WebSubClient/WebSubClient.cshtml: -------------------------------------------------------------------------------- 1 | @model FHIRcastSandbox.Model.ClientModel 2 | @{ 3 | Layout = null; 4 | } 5 | 6 | 7 | 8 | 9 | 10 | @**@ 11 | View 12 | 13 | 14 |

FHIRcast Sandbox

15 |
16 | 22 |
23 | @* Client info column *@ 24 |
25 |

Client info

26 | @* Title row *@ 27 |
28 |

Subscription info

29 |

Context

30 |
31 | @* Content row *@ 32 |
33 | @* Subscription column *@ 34 |
35 | @* Create new subscriptions form *@ 36 |
37 |
38 |
39 |
40 | @{ 41 | using (Html.BeginForm("subscribe", "WebSubClient", FormMethod.Post, new { id = "subscribe" })) 42 | { 43 | 44 |
45 |
46 | 47 | 50 |
51 | Http Headers 52 |
53 |
54 | 55 |
56 |
57 |
58 | 59 |
60 |
61 | 62 |
63 | 64 |
65 |
66 | Open Patient 67 | Close Patient 68 | Open Study 69 | Close Study 70 | 71 |
72 | 73 |
74 | 75 |
76 |
77 |
78 | } 79 | } 80 |
81 |
82 |
83 |
84 | @* Table of active subscriptions *@ 85 |
86 |
87 |
88 |
89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 |
Client's Subscriptions
Hub URLTopicEvents
100 |
101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 |
Client's Subscribers
Callback URLTopicEvents
112 |
113 |
114 |
115 |
116 |
117 | @* User session column *@ 118 |
119 |
120 |
121 | @{ 122 | using (Html.BeginForm("update", "WebSubClient", FormMethod.Post, new { id = "update" })) 123 | { 124 |
125 |
126 | @* Patient fields *@ 127 |
128 |
129 |

Patient

130 | 131 | 132 |
133 |
134 |
135 | 136 | @* Study fields *@ 137 |
138 |
139 |

ImagingStudy

140 | 141 | 142 |
143 |
144 |
145 | 146 |
147 |
148 | 149 | 150 |
151 |
152 |
153 | 154 |
155 |
156 | 157 | 158 |
159 |
160 |
161 |
162 |
163 | 164 |
165 | 166 |
167 | } 168 | } 169 |
170 |
171 |
172 |
173 | @* Miscellaneous row *@ 174 |
175 |
176 |
177 | @{ 178 | using (Html.BeginForm("Refresh", "WebSubClient", FormMethod.Post)) 179 | { 180 |
181 | 182 |
183 | } 184 | } 185 |
186 | 187 | 188 | 189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 | @* Hub info column *@ 197 |
198 |

Hub info

199 | @* Hub subscriptions row *@ 200 |
201 |
202 |
203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | @*@if (Model.SubscriptionsToHub != null) 214 | { 215 | @foreach (var item in Model.SubscriptionsToHub) 216 | { 217 | 218 | 219 | 220 | 221 | 222 | } 223 | }*@ 224 | 225 |
Subscriptions to Hub
Callback URLTopicEvents
@item.Callback.ToString().Substring(0, item.Callback.ToString().LastIndexOf('/'))/{subscriptionId}@item.Topic@String.Join(",", item.Events)
226 |
227 |
228 |
229 |
230 |
231 |
232 | 233 | 234 | 235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /WebSubClient/WebSubClient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.1 5 | FHIRcastSandbox.WebSubClient 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | PreserveNewest 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /WebSubClient/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "LogLevel": { 5 | "Default": "Debug", 6 | "System": "Information", 7 | "Microsoft": "Information" 8 | }, 9 | "Console": { 10 | "LogLevel": { 11 | "Default": "Debug" 12 | } 13 | } 14 | }, 15 | "Settings": { 16 | "ValidateSubscriptionValidations": "True", 17 | "HubBaseURL": "localhost", 18 | "HubPort": 5000, 19 | "ClientBaseURL": "localhost", 20 | "ClientPort": 5001 21 | } 22 | } -------------------------------------------------------------------------------- /WebSubClient/appsettings.Docker.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "LogLevel": { 5 | "Default": "Warning" 6 | } 7 | }, 8 | "Settings": { 9 | "ValidateSubscriptionValidations": "True", 10 | "HubBaseURL": "hub", 11 | "HubPort": 80, 12 | "ClientBaseURL": "client", 13 | "ClientPort": 80 14 | } 15 | } -------------------------------------------------------------------------------- /WebSubClient/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "LogLevel": { 5 | "Default": "Warning" 6 | } 7 | }, 8 | "Settings": { 9 | "ValidateSubscriptionValidations": "True", 10 | "HubBaseURL": "localhost", 11 | "HubPort": 5000, 12 | "ClientBaseURL": "localhost", 13 | "ClientPort": 5001 14 | } 15 | } -------------------------------------------------------------------------------- /WebSubClient/bundleconfig.json: -------------------------------------------------------------------------------- 1 | // Configure bundling and minification for the project. 2 | // More info at https://go.microsoft.com/fwlink/?LinkId=808241 3 | [ 4 | { 5 | "outputFileName": "wwwroot/css/site.min.css", 6 | // An array of relative input file paths. Globbing patterns supported 7 | "inputFiles": [ 8 | "wwwroot/css/site.css" 9 | ] 10 | }, 11 | { 12 | "outputFileName": "wwwroot/js/site.min.js", 13 | "inputFiles": [ 14 | "wwwroot/js/site.js" 15 | ], 16 | // Optionally specify minification options 17 | "minify": { 18 | "enabled": true, 19 | "renameLocals": true 20 | }, 21 | // Optionally generate .map file 22 | "sourceMap": false 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /WebSubClient/nlog.config: -------------------------------------------------------------------------------- 1 |  2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /WebSubClient/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WebSubClient", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@aspnet/signalr": { 8 | "version": "1.0.2", 9 | "resolved": "https://registry.npmjs.org/@aspnet/signalr/-/signalr-1.0.2.tgz", 10 | "integrity": "sha512-sXleqUCCbodCOqUA8MjLSvtAgDTvDhEq6j3JyAq/w4RMJhpZ+dXK9+6xEMbzag2hisq5e/8vDC82JYutkcOISQ==" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /WebSubClient/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WebSubClient", 3 | "version": "1.0.0", 4 | "description": "This project serves to exemplify how a WebSub client could be built towards a FHIRcast Hub. The client consists of a server component and a web UI, but only the server component communicates directly with the Hub. The client and the server component communicates using proprietary means outside the FHIRcast standard. In order to have a user-facing client communicate with a FHIRcast Hub directly, see the [WebSocket addition to the standard][fhircast-websocket] (TODO: Add correct link here).", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@aspnet/signalr": "^1.0.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /WebSubClient/wwwroot/css/site.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 50px; 3 | padding-bottom: 20px; 4 | } 5 | 6 | /* Wrapping element */ 7 | /* Set some basic padding to keep content from hitting the edges */ 8 | .body-content { 9 | padding-left: 15px; 10 | padding-right: 15px; 11 | } 12 | 13 | /* Carousel */ 14 | .carousel-caption p { 15 | font-size: 20px; 16 | line-height: 1.4; 17 | } 18 | 19 | /* Make .svg files in the carousel display properly in older browsers */ 20 | .carousel-inner .item img[src$=".svg"] { 21 | width: 100%; 22 | } 23 | 24 | /* QR code generator */ 25 | #qrCode { 26 | margin: 15px; 27 | } 28 | 29 | /* Hide/rearrange for smaller screens */ 30 | @media screen and (max-width: 767px) { 31 | /* Hide captions */ 32 | .carousel-caption { 33 | display: none; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /WebSubClient/wwwroot/css/site.min.css: -------------------------------------------------------------------------------- 1 | body{padding-top:50px;padding-bottom:20px}.body-content{padding-left:15px;padding-right:15px}.carousel-caption p{font-size:20px;line-height:1.4}.carousel-inner .item img[src$=".svg"]{width:100%}#qrCode{margin:15px}@media screen and (max-width:767px){.carousel-caption{display:none}} -------------------------------------------------------------------------------- /WebSubClient/wwwroot/data/clientContextDefinition.json: -------------------------------------------------------------------------------- 1 | { 2 | "Resources": [ 3 | { 4 | "name": "Patient", 5 | "properties": { 6 | "patientId": "Patient ID" 7 | } 8 | }, 9 | { 10 | "name": "ImagingStudy", 11 | "properties": { 12 | "accession": "Accession Number" 13 | } 14 | } 15 | ], 16 | "topic": "Latest Topic", 17 | "event": "Latest Event" 18 | } 19 | -------------------------------------------------------------------------------- /WebSubClient/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/WebSubClient/wwwroot/favicon.ico -------------------------------------------------------------------------------- /WebSubClient/wwwroot/js/site.js: -------------------------------------------------------------------------------- 1 | // Write your JavaScript code. 2 | 3 | 4 | //#region Event Definitions 5 | const eventResources = { 6 | PATIENT: 'patient', 7 | IMAGINGSTUDY: 'imagingstudy' 8 | } 9 | 10 | const eventActions = { 11 | OPEN: 'open', 12 | CLOSE: 'close' 13 | } 14 | 15 | class NotificationEvent { 16 | resourceType = ""; 17 | actionType = ""; 18 | 19 | // Resource and action should be eventResources and eventActions types 20 | constructor(resource, action) { 21 | this.resourceType = resource; 22 | this.actionType = action; 23 | } 24 | } 25 | //#endregion 26 | 27 | 28 | const connection = new signalR.HubConnectionBuilder() 29 | .withUrl("/websubclienthub") 30 | .configureLogging(signalR.LogLevel.Information) 31 | .build(); 32 | 33 | var clientTopic = ""; 34 | 35 | connection.start() 36 | .then(function () { 37 | connection.invoke("getTopic") 38 | .then((topic) => { 39 | clientTopic = topic; 40 | document.getElementById("topic").value = clientTopic; 41 | }) 42 | .catch(e => handleError(e)); 43 | }) 44 | .catch(err => handleError(err)); 45 | 46 | //#region SignalR Connection Functions 47 | // Handles receiving a notification from one of our subscriptions 48 | connection.on("ReceivedNotification", (notification) => { 49 | popupNotification("Received " + notification.event["hub.event"] + " notification"); 50 | 51 | let eventObj = parseNotificationIntoEventObj(notification); 52 | var ctrl, value; 53 | 54 | if (eventObj.resourceType === eventResources.PATIENT) { 55 | ctrl = this["patientID"]; 56 | } else if (eventObj.resourceType === eventResources.IMAGINGSTUDY) { 57 | ctrl = this["accessionNumber"]; 58 | } 59 | 60 | if (eventObj.actionType === eventActions.OPEN) { 61 | value = notification.event.context[0].idElement.value; // Just assume the first resource for now. 62 | } else if (eventObj.actionType === eventActions.CLOSE) { 63 | value = ""; 64 | } 65 | 66 | ctrl.value = value; 67 | }); 68 | 69 | function parseNotificationIntoEventObj(notification) { 70 | var eventString = notification.event["hub.event"]; 71 | 72 | // This parsing will change with the upcoming spec changes 73 | var pieces = eventString.split("-"); 74 | 75 | let event = new NotificationEvent(pieces[1], pieces[0]); 76 | return event; 77 | } 78 | 79 | connection.on("SubscriptionsChanged", (subscriptions) => { 80 | var subTable = getSubscriptionTable(false).getElementsByTagName('tbody')[0]; 81 | subTable.innerHTML = ""; 82 | 83 | for (var i = 0; i < subscriptions.length; i++) { 84 | addSubscriptionToTable(subTable, subscriptions[i]); 85 | } 86 | }); 87 | 88 | // Handles adding a verified subscription to this client 89 | connection.on("SubscriberAdded", (subscription) => { 90 | popupNotification("New subscriber " + subscription.callback); 91 | 92 | var subTable = getSubscriptionTable(true).getElementsByTagName('tbody')[0]; 93 | addSubscriptionToTable(subTable, subscription); 94 | }); 95 | 96 | // Handles receiving a message from the hub to be displayed to the user 97 | connection.on("AlertMessage", (message) => { 98 | popupNotification(message); 99 | }); 100 | //#endregion 101 | 102 | function getSubscriptionTable(subscribers) { 103 | var tableID = ""; 104 | if (subscribers) { 105 | tableID = "clientsSubscribersTable"; 106 | } else { 107 | tableID = "clientsSubscriptionTable"; 108 | } 109 | return document.getElementById(tableID); 110 | } 111 | 112 | function addSubscriptionToTable(table, subscription) { 113 | var newRow = table.insertRow(table.rows.length); 114 | 115 | var urlCell = newRow.insertCell(0); 116 | var topicCell = newRow.insertCell(1); 117 | var eventCell = newRow.insertCell(2); 118 | var unsubscribeCell = newRow.insertCell(3); 119 | 120 | 121 | var url = ""; 122 | if (subscription.hubDetails && subscription.hubDetails.hubUrl) { 123 | url = subscription.hubDetails.hubUrl; 124 | } else { 125 | url = subscription.callback; 126 | } 127 | 128 | var urlText = document.createTextNode(url); 129 | var topicText = document.createTextNode(subscription.topic); 130 | var eventsText = document.createTextNode(subscription.events.join(",")); 131 | var unsubscribeBtn = document.createElement('input'); 132 | unsubscribeBtn.type = "button"; 133 | unsubscribeBtn.className = "btn btn-secondary"; 134 | unsubscribeBtn.value = "Unsubscribe"; 135 | unsubscribeBtn.id = "unsub"; 136 | unsubscribeBtn.onclick = (function (topic) { 137 | return function () { 138 | unsubscribe(subscription.topic); 139 | }; 140 | })(subscription.topic); 141 | 142 | urlCell.appendChild(urlText); 143 | topicCell.appendChild(topicText); 144 | eventCell.appendChild(eventsText); 145 | unsubscribeCell.appendChild(unsubscribeBtn); 146 | } 147 | 148 | function addHttpHeader() { 149 | var tbl = document.getElementById("tblHttpHeaders"); 150 | 151 | var newRow = tbl.insertRow(tbl.rows.length); 152 | 153 | var nameCell = newRow.insertCell(); 154 | var valueCell = newRow.insertCell(); 155 | 156 | var nameText = document.createElement('input'); 157 | nameText.type = "text"; 158 | 159 | var valueText = document.createElement('input'); 160 | valueText.type = "text"; 161 | 162 | nameCell.appendChild(nameText); 163 | valueCell.appendChild(valueText); 164 | } 165 | 166 | //#region Button events 167 | $("#subscribe").submit(function (e) { 168 | let form = $(this); 169 | let url = form.attr("action"); 170 | 171 | console.debug("subscribing to " + this["subscriptionUrl"].value); 172 | 173 | var tbl = document.getElementById("tblHttpHeaders"); 174 | let headers = []; 175 | for (var i = 0; i < tbl.rows.length; i++) { 176 | if (tbl.rows[i].cells[0].children[0].value === "") { 177 | continue; 178 | } 179 | headers[i] = tbl.rows[i].cells[0].children[0].value + ":" + tbl.rows[i].cells[1].children[0].value; 180 | } 181 | 182 | let eventChkBoxes = ["chkOpenPatient", "chkClosePatient", "chkOpenStudy", "chkCloseStudy"]; 183 | let events = ""; 184 | for (var j = 0; j < eventChkBoxes.length; j++) { 185 | if (this[eventChkBoxes[j]].checked) { 186 | if (events === "") { 187 | events = this[eventChkBoxes[j]].value; 188 | } else { 189 | events += "," + this[eventChkBoxes[j]].value; 190 | } 191 | } 192 | } 193 | if (this["events"].value !== "") { 194 | if (events === "") { 195 | events = this["events"].value; 196 | } else { 197 | events += "," + this["events"].value; 198 | } 199 | } 200 | 201 | connection 202 | .invoke( 203 | "subscribe", 204 | this["subscriptionUrl"].value, 205 | this["topic"].value, 206 | events, 207 | headers) 208 | .catch(e => console.error(e)); 209 | 210 | e.preventDefault(); 211 | }); 212 | 213 | // Event handler function for Unsubscribe buttons in subscription table. 214 | function unsubscribe(topic) { 215 | console.debug("unsubscribing from " + topic); 216 | 217 | connection 218 | .invoke( 219 | "unsubscribe", topic) 220 | .catch(e => console.error(e)); 221 | } 222 | 223 | $("#update").submit(function (e) { 224 | var clientModel = { 225 | PatientID: this["patientID"].value, 226 | AccessionNumber: this["accessionNumber"].value 227 | }; 228 | 229 | connection 230 | .invoke("update", 231 | clientTopic, 232 | this["event"].value, 233 | clientModel) 234 | .catch(e => handleError(e)); 235 | 236 | e.preventDefault(); 237 | }); 238 | //#endregion 239 | 240 | //#region Alert Functions 241 | //These functions handle the popup header notification. Use it for minimalist notifications that don't require 242 | //user input since the popup will fade and disappear after a short time. 243 | function handleError(error) { 244 | console.log(error); 245 | popupNotification(error.message); 246 | } 247 | 248 | function popupNotification(message) { 249 | if (!alertDivExists()) { 250 | addAlertDiv(); 251 | } 252 | 253 | var alertTextSpan = document.getElementById("alertText"); 254 | 255 | alertTextSpan.innerHTML = message; 256 | $('#alertPlaceholder').fadeIn(1); 257 | setTimeout(notificationTimeout, 3000); 258 | } 259 | 260 | function notificationTimeout() { 261 | $('#alertPlaceholder').fadeOut(3000, "swing", clearNotification); 262 | } 263 | 264 | function clearNotification() { 265 | if (alertDivExists()) { 266 | document.getElementById("alertText").innerHTML = ""; 267 | } 268 | } 269 | 270 | function alertDivExists() { 271 | return (document.getElementById("alertMessageDiv") !== null); 272 | } 273 | 274 | function addAlertDiv() { 275 | var closeButton = document.createElement("a"); 276 | closeButton.setAttribute("class", "close"); 277 | closeButton.setAttribute("data-dismiss", "alert"); 278 | closeButton.innerHTML = "x"; 279 | 280 | var textSpan = document.createElement("span"); 281 | textSpan.setAttribute("id", "alertText"); 282 | 283 | var element = document.createElement("div"); 284 | element.setAttribute("id", "alertMessageDiv"); 285 | element.setAttribute("class", "alert alert-warning alert-dismissable"); 286 | element.appendChild(closeButton); 287 | element.appendChild(textSpan); 288 | 289 | document.getElementById("alertPlaceholder").appendChild(element); 290 | return textSpan; 291 | } 292 | //#endregion 293 | 294 | -------------------------------------------------------------------------------- /WebSubClient/wwwroot/js/site.min.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/WebSubClient/wwwroot/js/site.min.js -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/bootstrap/.bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bootstrap", 3 | "description": "The most popular front-end framework for developing responsive, mobile first projects on the web.", 4 | "keywords": [ 5 | "css", 6 | "js", 7 | "less", 8 | "mobile-first", 9 | "responsive", 10 | "front-end", 11 | "framework", 12 | "web" 13 | ], 14 | "homepage": "http://getbootstrap.com", 15 | "license": "MIT", 16 | "moduleType": "globals", 17 | "main": [ 18 | "less/bootstrap.less", 19 | "dist/js/bootstrap.js" 20 | ], 21 | "ignore": [ 22 | "/.*", 23 | "_config.yml", 24 | "CNAME", 25 | "composer.json", 26 | "CONTRIBUTING.md", 27 | "docs", 28 | "js/tests", 29 | "test-infra" 30 | ], 31 | "dependencies": { 32 | "jquery": "1.9.1 - 3" 33 | }, 34 | "version": "3.3.7", 35 | "_release": "3.3.7", 36 | "_resolution": { 37 | "type": "version", 38 | "tag": "v3.3.7", 39 | "commit": "0b9c4a4007c44201dce9a6cc1a38407005c26c86" 40 | }, 41 | "_source": "https://github.com/twbs/bootstrap.git", 42 | "_target": "v3.3.7", 43 | "_originalSource": "bootstrap", 44 | "_direct": true 45 | } -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/bootstrap/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2016 Twitter, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhircast/sandbox/fd9583ba513c8c4e5e10b864bd748d6d29b58d53/WebSubClient/wwwroot/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/bootstrap/dist/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/jquery-validation-unobtrusive/.bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery-validation-unobtrusive", 3 | "version": "3.2.6", 4 | "homepage": "https://github.com/aspnet/jquery-validation-unobtrusive", 5 | "description": "Add-on to jQuery Validation to enable unobtrusive validation options in data-* attributes.", 6 | "main": [ 7 | "jquery.validate.unobtrusive.js" 8 | ], 9 | "ignore": [ 10 | "**/.*", 11 | "*.json", 12 | "*.md", 13 | "*.txt", 14 | "gulpfile.js" 15 | ], 16 | "keywords": [ 17 | "jquery", 18 | "asp.net", 19 | "mvc", 20 | "validation", 21 | "unobtrusive" 22 | ], 23 | "authors": [ 24 | "Microsoft" 25 | ], 26 | "license": "http://www.microsoft.com/web/webpi/eula/net_library_eula_enu.htm", 27 | "repository": { 28 | "type": "git", 29 | "url": "git://github.com/aspnet/jquery-validation-unobtrusive.git" 30 | }, 31 | "dependencies": { 32 | "jquery-validation": ">=1.8", 33 | "jquery": ">=1.8" 34 | }, 35 | "_release": "3.2.6", 36 | "_resolution": { 37 | "type": "version", 38 | "tag": "v3.2.6", 39 | "commit": "13386cd1b5947d8a5d23a12b531ce3960be1eba7" 40 | }, 41 | "_source": "git://github.com/aspnet/jquery-validation-unobtrusive.git", 42 | "_target": "3.2.6", 43 | "_originalSource": "jquery-validation-unobtrusive" 44 | } -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | ** Unobtrusive validation support library for jQuery and jQuery Validate 3 | ** Copyright (C) Microsoft Corporation. All rights reserved. 4 | */ 5 | !function(a){function e(a,e,n){a.rules[e]=n,a.message&&(a.messages[e]=a.message)}function n(a){return a.replace(/^\s+|\s+$/g,"").split(/\s*,\s*/g)}function t(a){return a.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g,"\\$1")}function r(a){return a.substr(0,a.lastIndexOf(".")+1)}function i(a,e){return 0===a.indexOf("*.")&&(a=a.replace("*.",e)),a}function o(e,n){var r=a(this).find("[data-valmsg-for='"+t(n[0].name)+"']"),i=r.attr("data-valmsg-replace"),o=i?a.parseJSON(i)!==!1:null;r.removeClass("field-validation-valid").addClass("field-validation-error"),e.data("unobtrusiveContainer",r),o?(r.empty(),e.removeClass("input-validation-error").appendTo(r)):e.hide()}function d(e,n){var t=a(this).find("[data-valmsg-summary=true]"),r=t.find("ul");r&&r.length&&n.errorList.length&&(r.empty(),t.addClass("validation-summary-errors").removeClass("validation-summary-valid"),a.each(n.errorList,function(){a("
  • ").html(this.message).appendTo(r)}))}function s(e){var n=e.data("unobtrusiveContainer");if(n){var t=n.attr("data-valmsg-replace"),r=t?a.parseJSON(t):null;n.addClass("field-validation-valid").removeClass("field-validation-error"),e.removeData("unobtrusiveContainer"),r&&n.empty()}}function l(e){var n=a(this),t="__jquery_unobtrusive_validation_form_reset";if(!n.data(t)){n.data(t,!0);try{n.data("validator").resetForm()}finally{n.removeData(t)}n.find(".validation-summary-errors").addClass("validation-summary-valid").removeClass("validation-summary-errors"),n.find(".field-validation-error").addClass("field-validation-valid").removeClass("field-validation-error").removeData("unobtrusiveContainer").find(">*").removeData("unobtrusiveContainer")}}function m(e){var n=a(e),t=n.data(v),r=a.proxy(l,e),i=p.unobtrusive.options||{},m=function(n,t){var r=i[n];r&&a.isFunction(r)&&r.apply(e,t)};return t||(t={options:{errorClass:i.errorClass||"input-validation-error",errorElement:i.errorElement||"span",errorPlacement:function(){o.apply(e,arguments),m("errorPlacement",arguments)},invalidHandler:function(){d.apply(e,arguments),m("invalidHandler",arguments)},messages:{},rules:{},success:function(){s.apply(e,arguments),m("success",arguments)}},attachValidation:function(){n.off("reset."+v,r).on("reset."+v,r).validate(this.options)},validate:function(){return n.validate(),n.valid()}},n.data(v,t)),t}var u,p=a.validator,v="unobtrusiveValidation";p.unobtrusive={adapters:[],parseElement:function(e,n){var t,r,i,o=a(e),d=o.parents("form")[0];d&&(t=m(d),t.options.rules[e.name]=r={},t.options.messages[e.name]=i={},a.each(this.adapters,function(){var n="data-val-"+this.name,t=o.attr(n),s={};void 0!==t&&(n+="-",a.each(this.params,function(){s[this]=o.attr(n+this)}),this.adapt({element:e,form:d,message:t,params:s,rules:r,messages:i}))}),a.extend(r,{__dummy__:!0}),n||t.attachValidation())},parse:function(e){var n=a(e),t=n.parents().addBack().filter("form").add(n.find("form")).has("[data-val=true]");n.find("[data-val=true]").each(function(){p.unobtrusive.parseElement(this,!0)}),t.each(function(){var a=m(this);a&&a.attachValidation()})}},u=p.unobtrusive.adapters,u.add=function(a,e,n){return n||(n=e,e=[]),this.push({name:a,params:e,adapt:n}),this},u.addBool=function(a,n){return this.add(a,function(t){e(t,n||a,!0)})},u.addMinMax=function(a,n,t,r,i,o){return this.add(a,[i||"min",o||"max"],function(a){var i=a.params.min,o=a.params.max;i&&o?e(a,r,[i,o]):i?e(a,n,i):o&&e(a,t,o)})},u.addSingleVal=function(a,n,t){return this.add(a,[n||"val"],function(r){e(r,t||a,r.params[n])})},p.addMethod("__dummy__",function(a,e,n){return!0}),p.addMethod("regex",function(a,e,n){var t;return this.optional(e)?!0:(t=new RegExp(n).exec(a),t&&0===t.index&&t[0].length===a.length)}),p.addMethod("nonalphamin",function(a,e,n){var t;return n&&(t=a.match(/\W/g),t=t&&t.length>=n),t}),p.methods.extension?(u.addSingleVal("accept","mimtype"),u.addSingleVal("extension","extension")):u.addSingleVal("extension","extension","accept"),u.addSingleVal("regex","pattern"),u.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url"),u.addMinMax("length","minlength","maxlength","rangelength").addMinMax("range","min","max","range"),u.addMinMax("minlength","minlength").addMinMax("maxlength","minlength","maxlength"),u.add("equalto",["other"],function(n){var o=r(n.element.name),d=n.params.other,s=i(d,o),l=a(n.form).find(":input").filter("[name='"+t(s)+"']")[0];e(n,"equalTo",l)}),u.add("required",function(a){("INPUT"!==a.element.tagName.toUpperCase()||"CHECKBOX"!==a.element.type.toUpperCase())&&e(a,"required",!0)}),u.add("remote",["url","type","additionalfields"],function(o){var d={url:o.params.url,type:o.params.type||"GET",data:{}},s=r(o.element.name);a.each(n(o.params.additionalfields||o.element.name),function(e,n){var r=i(n,s);d.data[r]=function(){var e=a(o.form).find(":input").filter("[name='"+t(r)+"']");return e.is(":checkbox")?e.filter(":checked").val()||e.filter(":hidden").val()||"":e.is(":radio")?e.filter(":checked").val()||"":e.val()}}),e(o,"remote",d)}),u.add("password",["min","nonalphamin","regex"],function(a){a.params.min&&e(a,"minlength",a.params.min),a.params.nonalphamin&&e(a,"nonalphamin",a.params.nonalphamin),a.params.regex&&e(a,"regex",a.params.regex)}),a(function(){p.unobtrusive.parse(document)})}(jQuery); -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/jquery-validation/.bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery-validation", 3 | "homepage": "http://jqueryvalidation.org/", 4 | "repository": { 5 | "type": "git", 6 | "url": "git://github.com/jzaefferer/jquery-validation.git" 7 | }, 8 | "authors": [ 9 | "Jörn Zaefferer " 10 | ], 11 | "description": "Form validation made easy", 12 | "main": "dist/jquery.validate.js", 13 | "keywords": [ 14 | "forms", 15 | "validation", 16 | "validate" 17 | ], 18 | "license": "MIT", 19 | "ignore": [ 20 | "**/.*", 21 | "node_modules", 22 | "bower_components", 23 | "test", 24 | "demo", 25 | "lib" 26 | ], 27 | "dependencies": { 28 | "jquery": ">= 1.7.2" 29 | }, 30 | "version": "1.14.0", 31 | "_release": "1.14.0", 32 | "_resolution": { 33 | "type": "version", 34 | "tag": "1.14.0", 35 | "commit": "c1343fb9823392aa9acbe1c3ffd337b8c92fed48" 36 | }, 37 | "_source": "git://github.com/jzaefferer/jquery-validation.git", 38 | "_target": ">=1.8", 39 | "_originalSource": "jquery-validation" 40 | } -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/jquery-validation/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright Jörn Zaefferer 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /WebSubClient/wwwroot/lib/jquery-validation/dist/additional-methods.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery Validation Plugin - v1.14.0 - 6/30/2015 2 | * http://jqueryvalidation.org/ 3 | * Copyright (c) 2015 Jörn Zaefferer; Licensed MIT */ 4 | !function(a){"function"==typeof define&&define.amd?define(["jquery","./jquery.validate.min"],a):a(jQuery)}(function(a){!function(){function b(a){return a.replace(/<.[^<>]*?>/g," ").replace(/ | /gi," ").replace(/[.(),;:!?%#$'\"_+=\/\-“”’]*/g,"")}a.validator.addMethod("maxWords",function(a,c,d){return this.optional(c)||b(a).match(/\b\w+\b/g).length<=d},a.validator.format("Please enter {0} words or less.")),a.validator.addMethod("minWords",function(a,c,d){return this.optional(c)||b(a).match(/\b\w+\b/g).length>=d},a.validator.format("Please enter at least {0} words.")),a.validator.addMethod("rangeWords",function(a,c,d){var e=b(a),f=/\b\w+\b/g;return this.optional(c)||e.match(f).length>=d[0]&&e.match(f).length<=d[1]},a.validator.format("Please enter between {0} and {1} words."))}(),a.validator.addMethod("accept",function(b,c,d){var e,f,g="string"==typeof d?d.replace(/\s/g,"").replace(/,/g,"|"):"image/*",h=this.optional(c);if(h)return h;if("file"===a(c).attr("type")&&(g=g.replace(/\*/g,".*"),c.files&&c.files.length))for(e=0;ec;c++)d=h-c,e=f.substring(c,c+1),g+=d*e;return g%11===0},"Please specify a valid bank account number"),a.validator.addMethod("bankorgiroaccountNL",function(b,c){return this.optional(c)||a.validator.methods.bankaccountNL.call(this,b,c)||a.validator.methods.giroaccountNL.call(this,b,c)},"Please specify a valid bank or giro account number"),a.validator.addMethod("bic",function(a,b){return this.optional(b)||/^([A-Z]{6}[A-Z2-9][A-NP-Z1-2])(X{3}|[A-WY-Z0-9][A-Z0-9]{2})?$/.test(a)},"Please specify a valid BIC code"),a.validator.addMethod("cifES",function(a){"use strict";var b,c,d,e,f,g,h=[];if(a=a.toUpperCase(),!a.match("((^[A-Z]{1}[0-9]{7}[A-Z0-9]{1}$|^[T]{1}[A-Z0-9]{8}$)|^[0-9]{8}[A-Z]{1}$)"))return!1;for(d=0;9>d;d++)h[d]=parseInt(a.charAt(d),10);for(c=h[2]+h[4]+h[6],e=1;8>e;e+=2)f=(2*h[e]).toString(),g=f.charAt(1),c+=parseInt(f.charAt(0),10)+(""===g?0:parseInt(g,10));return/^[ABCDEFGHJNPQRSUVW]{1}/.test(a)?(c+="",b=10-parseInt(c.charAt(c.length-1),10),a+=b,h[8].toString()===String.fromCharCode(64+b)||h[8].toString()===a.charAt(a.length-1)):!1},"Please specify a valid CIF number."),a.validator.addMethod("cpfBR",function(a){if(a=a.replace(/([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g,""),11!==a.length)return!1;var b,c,d,e,f=0;if(b=parseInt(a.substring(9,10),10),c=parseInt(a.substring(10,11),10),d=function(a,b){var c=10*a%11;return(10===c||11===c)&&(c=0),c===b},""===a||"00000000000"===a||"11111111111"===a||"22222222222"===a||"33333333333"===a||"44444444444"===a||"55555555555"===a||"66666666666"===a||"77777777777"===a||"88888888888"===a||"99999999999"===a)return!1;for(e=1;9>=e;e++)f+=parseInt(a.substring(e-1,e),10)*(11-e);if(d(f,b)){for(f=0,e=1;10>=e;e++)f+=parseInt(a.substring(e-1,e),10)*(12-e);return d(f,c)}return!1},"Please specify a valid CPF number"),a.validator.addMethod("creditcardtypes",function(a,b,c){if(/[^0-9\-]+/.test(a))return!1;a=a.replace(/\D/g,"");var d=0;return c.mastercard&&(d|=1),c.visa&&(d|=2),c.amex&&(d|=4),c.dinersclub&&(d|=8),c.enroute&&(d|=16),c.discover&&(d|=32),c.jcb&&(d|=64),c.unknown&&(d|=128),c.all&&(d=255),1&d&&/^(5[12345])/.test(a)?16===a.length:2&d&&/^(4)/.test(a)?16===a.length:4&d&&/^(3[47])/.test(a)?15===a.length:8&d&&/^(3(0[012345]|[68]))/.test(a)?14===a.length:16&d&&/^(2(014|149))/.test(a)?15===a.length:32&d&&/^(6011)/.test(a)?16===a.length:64&d&&/^(3)/.test(a)?16===a.length:64&d&&/^(2131|1800)/.test(a)?15===a.length:128&d?!0:!1},"Please enter a valid credit card number."),a.validator.addMethod("currency",function(a,b,c){var d,e="string"==typeof c,f=e?c:c[0],g=e?!0:c[1];return f=f.replace(/,/g,""),f=g?f+"]":f+"]?",d="^["+f+"([1-9]{1}[0-9]{0,2}(\\,[0-9]{3})*(\\.[0-9]{0,2})?|[1-9]{1}[0-9]{0,}(\\.[0-9]{0,2})?|0(\\.[0-9]{0,2})?|(\\.[0-9]{1,2})?)$",d=new RegExp(d),this.optional(b)||d.test(a)},"Please specify a valid currency"),a.validator.addMethod("dateFA",function(a,b){return this.optional(b)||/^[1-4]\d{3}\/((0?[1-6]\/((3[0-1])|([1-2][0-9])|(0?[1-9])))|((1[0-2]|(0?[7-9]))\/(30|([1-2][0-9])|(0?[1-9]))))$/.test(a)},a.validator.messages.date),a.validator.addMethod("dateITA",function(a,b){var c,d,e,f,g,h=!1,i=/^\d{1,2}\/\d{1,2}\/\d{4}$/;return i.test(a)?(c=a.split("/"),d=parseInt(c[0],10),e=parseInt(c[1],10),f=parseInt(c[2],10),g=new Date(Date.UTC(f,e-1,d,12,0,0,0)),h=g.getUTCFullYear()===f&&g.getUTCMonth()===e-1&&g.getUTCDate()===d?!0:!1):h=!1,this.optional(b)||h},a.validator.messages.date),a.validator.addMethod("dateNL",function(a,b){return this.optional(b)||/^(0?[1-9]|[12]\d|3[01])[\.\/\-](0?[1-9]|1[012])[\.\/\-]([12]\d)?(\d\d)$/.test(a)},a.validator.messages.date),a.validator.addMethod("extension",function(a,b,c){return c="string"==typeof c?c.replace(/,/g,"|"):"png|jpe?g|gif",this.optional(b)||a.match(new RegExp("\\.("+c+")$","i"))},a.validator.format("Please enter a value with a valid extension.")),a.validator.addMethod("giroaccountNL",function(a,b){return this.optional(b)||/^[0-9]{1,7}$/.test(a)},"Please specify a valid giro account number"),a.validator.addMethod("iban",function(a,b){if(this.optional(b))return!0;var c,d,e,f,g,h,i,j,k,l=a.replace(/ /g,"").toUpperCase(),m="",n=!0,o="",p="";if(c=l.substring(0,2),h={AL:"\\d{8}[\\dA-Z]{16}",AD:"\\d{8}[\\dA-Z]{12}",AT:"\\d{16}",AZ:"[\\dA-Z]{4}\\d{20}",BE:"\\d{12}",BH:"[A-Z]{4}[\\dA-Z]{14}",BA:"\\d{16}",BR:"\\d{23}[A-Z][\\dA-Z]",BG:"[A-Z]{4}\\d{6}[\\dA-Z]{8}",CR:"\\d{17}",HR:"\\d{17}",CY:"\\d{8}[\\dA-Z]{16}",CZ:"\\d{20}",DK:"\\d{14}",DO:"[A-Z]{4}\\d{20}",EE:"\\d{16}",FO:"\\d{14}",FI:"\\d{14}",FR:"\\d{10}[\\dA-Z]{11}\\d{2}",GE:"[\\dA-Z]{2}\\d{16}",DE:"\\d{18}",GI:"[A-Z]{4}[\\dA-Z]{15}",GR:"\\d{7}[\\dA-Z]{16}",GL:"\\d{14}",GT:"[\\dA-Z]{4}[\\dA-Z]{20}",HU:"\\d{24}",IS:"\\d{22}",IE:"[\\dA-Z]{4}\\d{14}",IL:"\\d{19}",IT:"[A-Z]\\d{10}[\\dA-Z]{12}",KZ:"\\d{3}[\\dA-Z]{13}",KW:"[A-Z]{4}[\\dA-Z]{22}",LV:"[A-Z]{4}[\\dA-Z]{13}",LB:"\\d{4}[\\dA-Z]{20}",LI:"\\d{5}[\\dA-Z]{12}",LT:"\\d{16}",LU:"\\d{3}[\\dA-Z]{13}",MK:"\\d{3}[\\dA-Z]{10}\\d{2}",MT:"[A-Z]{4}\\d{5}[\\dA-Z]{18}",MR:"\\d{23}",MU:"[A-Z]{4}\\d{19}[A-Z]{3}",MC:"\\d{10}[\\dA-Z]{11}\\d{2}",MD:"[\\dA-Z]{2}\\d{18}",ME:"\\d{18}",NL:"[A-Z]{4}\\d{10}",NO:"\\d{11}",PK:"[\\dA-Z]{4}\\d{16}",PS:"[\\dA-Z]{4}\\d{21}",PL:"\\d{24}",PT:"\\d{21}",RO:"[A-Z]{4}[\\dA-Z]{16}",SM:"[A-Z]\\d{10}[\\dA-Z]{12}",SA:"\\d{2}[\\dA-Z]{18}",RS:"\\d{18}",SK:"\\d{20}",SI:"\\d{15}",ES:"\\d{20}",SE:"\\d{20}",CH:"\\d{5}[\\dA-Z]{12}",TN:"\\d{20}",TR:"\\d{5}[\\dA-Z]{17}",AE:"\\d{3}\\d{16}",GB:"[A-Z]{4}\\d{14}",VG:"[\\dA-Z]{4}\\d{16}"},g=h[c],"undefined"!=typeof g&&(i=new RegExp("^[A-Z]{2}\\d{2}"+g+"$",""),!i.test(l)))return!1;for(d=l.substring(4,l.length)+l.substring(0,4),j=0;j9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?|0)7(?:[1345789]\d{2}|624)\s?\d{3}\s?\d{3})$/)},"Please specify a valid mobile number"),a.validator.addMethod("nieES",function(a){"use strict";return a=a.toUpperCase(),a.match("((^[A-Z]{1}[0-9]{7}[A-Z0-9]{1}$|^[T]{1}[A-Z0-9]{8}$)|^[0-9]{8}[A-Z]{1}$)")?/^[T]{1}/.test(a)?a[8]===/^[T]{1}[A-Z0-9]{8}$/.test(a):/^[XYZ]{1}/.test(a)?a[8]==="TRWAGMYFPDXBNJZSQVHLCKE".charAt(a.replace("X","0").replace("Y","1").replace("Z","2").substring(0,8)%23):!1:!1},"Please specify a valid NIE number."),a.validator.addMethod("nifES",function(a){"use strict";return a=a.toUpperCase(),a.match("((^[A-Z]{1}[0-9]{7}[A-Z0-9]{1}$|^[T]{1}[A-Z0-9]{8}$)|^[0-9]{8}[A-Z]{1}$)")?/^[0-9]{8}[A-Z]{1}$/.test(a)?"TRWAGMYFPDXBNJZSQVHLCKE".charAt(a.substring(8,0)%23)===a.charAt(8):/^[KLM]{1}/.test(a)?a[8]===String.fromCharCode(64):!1:!1},"Please specify a valid NIF number."),jQuery.validator.addMethod("notEqualTo",function(b,c,d){return this.optional(c)||!a.validator.methods.equalTo.call(this,b,c,d)},"Please enter a different value, values must not be the same."),a.validator.addMethod("nowhitespace",function(a,b){return this.optional(b)||/^\S+$/i.test(a)},"No white space please"),a.validator.addMethod("pattern",function(a,b,c){return this.optional(b)?!0:("string"==typeof c&&(c=new RegExp("^(?:"+c+")$")),c.test(a))},"Invalid format."),a.validator.addMethod("phoneNL",function(a,b){return this.optional(b)||/^((\+|00(\s|\s?\-\s?)?)31(\s|\s?\-\s?)?(\(0\)[\-\s]?)?|0)[1-9]((\s|\s?\-\s?)?[0-9]){8}$/.test(a)},"Please specify a valid phone number."),a.validator.addMethod("phoneUK",function(a,b){return a=a.replace(/\(|\)|\s+|-/g,""),this.optional(b)||a.length>9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?)|(?:\(?0))(?:\d{2}\)?\s?\d{4}\s?\d{4}|\d{3}\)?\s?\d{3}\s?\d{3,4}|\d{4}\)?\s?(?:\d{5}|\d{3}\s?\d{3})|\d{5}\)?\s?\d{4,5})$/)},"Please specify a valid phone number"),a.validator.addMethod("phoneUS",function(a,b){return a=a.replace(/\s+/g,""),this.optional(b)||a.length>9&&a.match(/^(\+?1-?)?(\([2-9]([02-9]\d|1[02-9])\)|[2-9]([02-9]\d|1[02-9]))-?[2-9]([02-9]\d|1[02-9])-?\d{4}$/)},"Please specify a valid phone number"),a.validator.addMethod("phonesUK",function(a,b){return a=a.replace(/\(|\)|\s+|-/g,""),this.optional(b)||a.length>9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?|0)(?:1\d{8,9}|[23]\d{9}|7(?:[1345789]\d{8}|624\d{6})))$/)},"Please specify a valid uk phone number"),a.validator.addMethod("postalCodeCA",function(a,b){return this.optional(b)||/^[ABCEGHJKLMNPRSTVXY]\d[A-Z] \d[A-Z]\d$/.test(a)},"Please specify a valid postal code"),a.validator.addMethod("postalcodeBR",function(a,b){return this.optional(b)||/^\d{2}.\d{3}-\d{3}?$|^\d{5}-?\d{3}?$/.test(a)},"Informe um CEP válido."),a.validator.addMethod("postalcodeIT",function(a,b){return this.optional(b)||/^\d{5}$/.test(a)},"Please specify a valid postal code"),a.validator.addMethod("postalcodeNL",function(a,b){return this.optional(b)||/^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/.test(a)},"Please specify a valid postal code"),a.validator.addMethod("postcodeUK",function(a,b){return this.optional(b)||/^((([A-PR-UWYZ][0-9])|([A-PR-UWYZ][0-9][0-9])|([A-PR-UWYZ][A-HK-Y][0-9])|([A-PR-UWYZ][A-HK-Y][0-9][0-9])|([A-PR-UWYZ][0-9][A-HJKSTUW])|([A-PR-UWYZ][A-HK-Y][0-9][ABEHMNPRVWXY]))\s?([0-9][ABD-HJLNP-UW-Z]{2})|(GIR)\s?(0AA))$/i.test(a)},"Please specify a valid UK postcode"),a.validator.addMethod("require_from_group",function(b,c,d){var e=a(d[1],c.form),f=e.eq(0),g=f.data("valid_req_grp")?f.data("valid_req_grp"):a.extend({},this),h=e.filter(function(){return g.elementValue(this)}).length>=d[0];return f.data("valid_req_grp",g),a(c).data("being_validated")||(e.data("being_validated",!0),e.each(function(){g.element(this)}),e.data("being_validated",!1)),h},a.validator.format("Please fill at least {0} of these fields.")),a.validator.addMethod("skip_or_fill_minimum",function(b,c,d){var e=a(d[1],c.form),f=e.eq(0),g=f.data("valid_skip")?f.data("valid_skip"):a.extend({},this),h=e.filter(function(){return g.elementValue(this)}).length,i=0===h||h>=d[0];return f.data("valid_skip",g),a(c).data("being_validated")||(e.data("being_validated",!0),e.each(function(){g.element(this)}),e.data("being_validated",!1)),i},a.validator.format("Please either skip these fields or fill at least {0} of them.")),a.validator.addMethod("stateUS",function(a,b,c){var d,e="undefined"==typeof c,f=e||"undefined"==typeof c.caseSensitive?!1:c.caseSensitive,g=e||"undefined"==typeof c.includeTerritories?!1:c.includeTerritories,h=e||"undefined"==typeof c.includeMilitary?!1:c.includeMilitary;return d=g||h?g&&h?"^(A[AEKLPRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$":g?"^(A[KLRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$":"^(A[AEKLPRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$":"^(A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$",d=f?new RegExp(d):new RegExp(d,"i"),this.optional(b)||d.test(a)},"Please specify a valid state"),a.validator.addMethod("strippedminlength",function(b,c,d){return a(b).text().length>=d},a.validator.format("Please enter at least {0} characters")),a.validator.addMethod("time",function(a,b){return this.optional(b)||/^([01]\d|2[0-3]|[0-9])(:[0-5]\d){1,2}$/.test(a)},"Please enter a valid time, between 00:00 and 23:59"),a.validator.addMethod("time12h",function(a,b){return this.optional(b)||/^((0?[1-9]|1[012])(:[0-5]\d){1,2}(\ ?[AP]M))$/i.test(a)},"Please enter a valid time in 12-hour am/pm format"),a.validator.addMethod("url2",function(a,b){return this.optional(b)||/^(https?|ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)*(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i.test(a)},a.validator.messages.url),a.validator.addMethod("vinUS",function(a){if(17!==a.length)return!1;var b,c,d,e,f,g,h=["A","B","C","D","E","F","G","H","J","K","L","M","N","P","R","S","T","U","V","W","X","Y","Z"],i=[1,2,3,4,5,6,7,8,1,2,3,4,5,7,9,2,3,4,5,6,7,8,9],j=[8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2],k=0;for(b=0;17>b;b++){if(e=j[b],d=a.slice(b,b+1),8===b&&(g=d),isNaN(d)){for(c=0;c