Skip to content

Commit c393e04

Browse files
committed
Updated documentation.
Modified code to split out the CS code from the ASHX files. Added the SlackToWorkflow webhook.
1 parent e1443be commit c393e04

6 files changed

Lines changed: 608 additions & 212 deletions

File tree

GenericWebhook.cs

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Data.Entity;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Web;
7+
using System.Text;
8+
using System.Text.RegularExpressions;
9+
10+
using Newtonsoft.Json;
11+
12+
using Rock;
13+
using Rock.Data;
14+
using Rock.Model;
15+
16+
namespace com.shepherdchurch.WebhookToWorkflow
17+
{
18+
/// <summary>
19+
/// Generic webhook to workflow implementation. Does basic decoding of FORM data
20+
/// and JSON data and provides basic HttpRequest information to the Workflow.
21+
/// </summary>
22+
public class GenericWebhook : IHttpHandler
23+
{
24+
/// <summary>
25+
/// The HttpContext related to this processing instance.
26+
/// </summary>
27+
protected HttpContext HttpContext { get; private set; }
28+
29+
/// <summary>
30+
/// The RockContext that has been setup for reading objects from the database
31+
/// for this instance.
32+
/// </summary>
33+
protected RockContext RockContext { get; private set; }
34+
35+
/// <summary>
36+
/// The partial URL of this request instance. This will always begin with a slash
37+
/// and contain all path elements after the .ashx file.
38+
/// </summary>
39+
protected string Url { get; private set; }
40+
41+
/// <summary>
42+
/// Process the incoming http request. This is the web handler entry point.
43+
/// </summary>
44+
/// <param name="context">The context that contains all information about this request.</param>
45+
public void ProcessRequest( HttpContext context )
46+
{
47+
DefinedValue hook;
48+
49+
try
50+
{
51+
//
52+
// Set the instance variables for subclasses to be able to use.
53+
//
54+
RockContext = new RockContext();
55+
HttpContext = context;
56+
Url = "/" + string.Join( "", HttpContext.Request.Url.Segments.SkipWhile( s => !s.EndsWith( ".ashx", StringComparison.InvariantCultureIgnoreCase ) && !s.EndsWith( ".ashx/", StringComparison.InvariantCultureIgnoreCase ) ).Skip( 1 ).ToArray() );
57+
58+
//
59+
// Find the hook for this type of instance.
60+
//
61+
hook = GetHookForRequest( DefinedTypeGuid() );
62+
63+
//
64+
// If we have a hook then try to find the workflow information.
65+
//
66+
if ( hook != null )
67+
{
68+
Guid guid = hook.GetAttributeValue( "WorkflowType" ).AsGuid();
69+
WorkflowType workflowType = new WorkflowTypeService( RockContext ).Get( guid );
70+
71+
if ( workflowType != null )
72+
{
73+
Workflow workflow = Workflow.Activate( workflowType, context.Request.UserHostName );
74+
75+
//
76+
// We found a workflow type and were able to instantiate a new one.
77+
//
78+
if ( workflow != null )
79+
{
80+
List<string> errorMessages;
81+
82+
//
83+
// Load in all the attributes that are defined in the workflow.
84+
//
85+
workflow.LoadAttributes();
86+
PopulateWorkflowAttributes( workflow, hook );
87+
88+
//
89+
// Execute the workflow.
90+
//
91+
new WorkflowService( RockContext ).Process( workflow, out errorMessages );
92+
93+
//
94+
// We send a response (if one is available) wether the workflow has ended
95+
// or not. This gives them a chance to send a "let me work on that for you"
96+
// type response and then continue processing in the background.
97+
//
98+
SendWorkflowResponse( workflow, hook );
99+
100+
context.Response.End();
101+
102+
return;
103+
}
104+
}
105+
}
106+
107+
//
108+
// If we got here then something went wrong, probably we couldn't find a matching hook.
109+
//
110+
context.Response.ContentType = "text/plain";
111+
context.Response.StatusCode = 404;
112+
context.Response.Write( "Path not found." );
113+
context.Response.End();
114+
}
115+
catch ( Exception e )
116+
{
117+
WriteToLog( e.Message );
118+
}
119+
}
120+
121+
/// <summary>
122+
/// These webhooks are not reusable and must only be used once.
123+
/// </summary>
124+
public bool IsReusable
125+
{
126+
get
127+
{
128+
return false;
129+
}
130+
}
131+
132+
#region Methods for subclass override
133+
134+
/// <summary>
135+
/// The GUID of the DefinedType to consider for matching webhooks. This
136+
/// should be overridden by subclasses to use their own DefinedType GUID.
137+
/// </summary>
138+
/// <returns>A Guid object which will identify the DefinedType.</returns>
139+
protected virtual Guid DefinedTypeGuid()
140+
{
141+
return new Guid( "dd5ba760-9942-4274-8b86-08691637e167" );
142+
}
143+
144+
/// <summary>
145+
/// Check if this DefinedValue is valid for the current request. Subclasses should
146+
/// call the base method so that the Method and Url are verified.
147+
/// </summary>
148+
/// <param name="hook">The DefinedValue that is to be considered for this request.</param>
149+
/// <returns>true if the DefinedValue matches this request, otherwise false.</returns>
150+
protected virtual bool IsHookValidForRequest( DefinedValue hook )
151+
{
152+
string hookUrl = hook.GetAttributeValue( "Url" );
153+
string hookMethod = hook.GetAttributeValue( "Method" );
154+
155+
//
156+
// Check for match on method type, if not continue to the next item.
157+
//
158+
if ( !string.IsNullOrEmpty( hookMethod ) && !HttpContext.Request.HttpMethod.ToString().Equals( hookMethod, StringComparison.InvariantCultureIgnoreCase ) )
159+
{
160+
return false;
161+
}
162+
163+
//
164+
// Check for match on the URL.
165+
//
166+
if ( string.IsNullOrEmpty( hookUrl ) )
167+
{
168+
return true;
169+
}
170+
else if ( hookUrl.StartsWith( "^" ) && hookUrl.EndsWith( "$" ) )
171+
{
172+
return Regex.IsMatch( Url, hookUrl, RegexOptions.IgnoreCase );
173+
}
174+
else
175+
{
176+
return Url.Equals( hookUrl, StringComparison.InvariantCultureIgnoreCase );
177+
}
178+
}
179+
180+
/// <summary>
181+
/// Populates any defined Workflow attributes specified to this webhook type.
182+
/// Subclasses may call the base method to have the Request attribute set.
183+
/// </summary>
184+
/// <param name="workflow">The workflow whose attributes need to be set.</param>
185+
/// <param name="hook">The DefinedValue of the currently executing webhook.</param>
186+
protected virtual void PopulateWorkflowAttributes( Workflow workflow, DefinedValue hook )
187+
{
188+
workflow.SetAttributeValue( "Request", JsonConvert.SerializeObject( RequestToDictionary( hook) ) );
189+
}
190+
191+
/// <summary>
192+
/// Send a response to the current request. By default anything in the Response
193+
/// workflow attribute is sent as text/plain content. Subclasses should override
194+
/// this method if they want a different type of response sent, or need to ensure
195+
/// that no response is ever sent.
196+
/// </summary>
197+
/// <param name="workflow">The workflow that can the response data.</param>
198+
/// <param name="hook">The DefinedValue of the currently executing webhook.</param>
199+
protected virtual void SendWorkflowResponse( Workflow workflow, DefinedValue hook )
200+
{
201+
string response = workflow.GetAttributeValue( "Response" );
202+
string contentType = workflow.GetAttributeValue( "ContentType" );
203+
204+
HttpContext.Response.ContentType = "text/plain";
205+
206+
if ( !string.IsNullOrEmpty( response ) )
207+
{
208+
HttpContext.Response.Write( response );
209+
}
210+
211+
if ( !string.IsNullOrWhiteSpace( contentType ) )
212+
{
213+
HttpContext.Response.ContentType = contentType;
214+
}
215+
}
216+
217+
/// <summary>
218+
/// Convert the request into a generic JSON object that can provide information
219+
/// to the workflow. If a subclass does needs to customize this data they can
220+
/// call the base method and then modify the content before returning it.
221+
/// </summary>
222+
/// <param name="hook">The DefinedValue of the currently executing webhook.</param>
223+
/// <returns></returns>
224+
protected virtual Dictionary<string, object> RequestToDictionary( DefinedValue hook )
225+
{
226+
var dictionary = new Dictionary<string, object>();
227+
228+
//
229+
// Set the standard values to be used.
230+
//
231+
dictionary.Add( "DefinedValueId", hook.Id );
232+
dictionary.Add( "Url", Url );
233+
dictionary.Add( "RawUrl", HttpContext.Request.Url.AbsoluteUri );
234+
dictionary.Add( "Method", HttpContext.Request.HttpMethod );
235+
dictionary.Add( "QueryString", HttpContext.Request.QueryString.Cast<string>().ToDictionary( q => q, q => HttpContext.Request.QueryString[q] ) );
236+
dictionary.Add( "RemoteAddress", HttpContext.Request.UserHostAddress );
237+
dictionary.Add( "RemoteName", HttpContext.Request.UserHostName );
238+
dictionary.Add( "ServerName", HttpContext.Request.Url.Host );
239+
240+
//
241+
// Add in the raw body content.
242+
//
243+
using ( StreamReader reader = new StreamReader( HttpContext.Request.InputStream, Encoding.UTF8 ) )
244+
{
245+
dictionary.Add( "RawBody", reader.ReadToEnd() );
246+
}
247+
248+
//
249+
// Parse the body content if it is JSON or standard Form data.
250+
//
251+
if ( HttpContext.Request.ContentType == "application/json" )
252+
{
253+
try
254+
{
255+
dictionary.Add( "Body", Newtonsoft.Json.JsonConvert.DeserializeObject( ( string )dictionary["RawBody"] ) );
256+
}
257+
catch
258+
{
259+
}
260+
}
261+
else if ( HttpContext.Request.ContentType == "application/x-www-form-urlencoded" )
262+
{
263+
try
264+
{
265+
dictionary.Add( "Body", HttpContext.Request.Form.Cast<string>().ToDictionary( q => q, q => HttpContext.Request.Form[q] ) );
266+
}
267+
catch
268+
{
269+
}
270+
}
271+
272+
//
273+
// Add in all the headers if the admin wants them.
274+
//
275+
if ( hook.GetAttributeValue( "Headers" ).AsBoolean() )
276+
{
277+
var headers = HttpContext.Request.Headers.Cast<string>()
278+
.Where( h => !h.Equals( "Authorization", StringComparison.InvariantCultureIgnoreCase ) )
279+
.Where( h => !h.Equals( "Cookie", StringComparison.InvariantCultureIgnoreCase ) )
280+
.ToDictionary( h => h, h => HttpContext.Request.Headers[h] );
281+
dictionary.Add( "Headers", headers );
282+
}
283+
284+
//
285+
// Add in all the cookies if the admin wants them.
286+
//
287+
if ( hook.GetAttributeValue( "Cookies" ).AsBoolean() )
288+
{
289+
dictionary.Add( "Cookies", HttpContext.Request.Cookies.Cast<string>().ToDictionary( q => q, q => HttpContext.Request.Cookies[q].Value ) );
290+
}
291+
292+
return dictionary;
293+
}
294+
295+
#endregion
296+
297+
#region Support methods
298+
299+
/// <summary>
300+
/// Retrieve the DefinedValue for this request by matching the Method, Url
301+
/// and any other filters defined by subclasses.
302+
/// </summary>
303+
/// <param name="definedTypeGuid">The GUID of the DefinedType whose values should be considered.</param>
304+
/// <returns>A DefinedValue for the webhook request that was matched or null if one was not found.</returns>
305+
protected DefinedValue GetHookForRequest( Guid definedTypeGuid )
306+
{
307+
DefinedType hooks = new DefinedTypeService( RockContext ).Get( definedTypeGuid );
308+
309+
foreach ( DefinedValue hook in hooks.DefinedValues.OrderBy( h => h.Order ) )
310+
{
311+
hook.LoadAttributes();
312+
313+
if ( IsHookValidForRequest( hook ) )
314+
{
315+
return hook;
316+
}
317+
}
318+
319+
return null;
320+
}
321+
322+
/// <summary>
323+
/// Log a message to the WebhookToWorkflow.txt file. The message is prefixed by
324+
/// the date and the class name.
325+
/// </summary>
326+
/// <param name="message">The message to be logged.</param>
327+
protected void WriteToLog( string message )
328+
{
329+
string logFile = HttpContext.Current.Server.MapPath( "~/App_Data/Logs/WebhookToWorkflow.txt" );
330+
331+
// Write to the log, but if an ioexception occurs wait a couple seconds and then try again (up to 3 times).
332+
var maxRetry = 3;
333+
for ( int retry = 0; retry < maxRetry; retry++ )
334+
{
335+
try
336+
{
337+
using ( FileStream fs = new FileStream( logFile, FileMode.Append, FileAccess.Write ) )
338+
{
339+
using ( StreamWriter sw = new StreamWriter( fs ) )
340+
{
341+
sw.WriteLine( string.Format( "{0} [{2}] - {1}", RockDateTime.Now.ToString(), message, GetType().Name ) );
342+
break;
343+
}
344+
}
345+
}
346+
catch ( IOException )
347+
{
348+
if ( retry < maxRetry - 1 )
349+
{
350+
System.Threading.Thread.Sleep( 2000 );
351+
}
352+
}
353+
}
354+
355+
}
356+
357+
#endregion
358+
}
359+
}

0 commit comments

Comments
 (0)