Swashbuckle.AspNetCore icon indicating copy to clipboard operation
Swashbuckle.AspNetCore copied to clipboard

Callbacks/webhooks support

Open racingcow opened this issue 6 years ago • 18 comments

Hi, and thanks for all the great work on this package.

OpenAPI 3.x / Swagger supports callbacks, but I can't seem to find any docs or code related to that in Swashbuckle. I have an ASP.NET Core API with webhooks that I'd like to use with Swashbuckle and display in the Swagger UI page.

  • Does Swashbuckle.AspNetCore support callbacks?
  • If so, how can I set that up? Using attributes, XML documentation, etc.?
  • If not, I'd like to make a feature request for it.

PS: Let me know if this is belongs on StackOverflow instead of here as per the contributing guidelines, and I'll move it.

racingcow avatar Dec 19 '18 21:12 racingcow

Any update on this issue? I'm also interested in it.

Jankowski-J avatar Apr 04 '19 10:04 Jankowski-J

Me as well!

knom avatar Dec 09 '19 08:12 knom

now that version 5.0 with openapi 3.0 support has been released, maybe we can revisit this?

pgrm avatar Feb 28 '20 12:02 pgrm

Would also love to see this implemented.

brechtvhb avatar Apr 02 '20 13:04 brechtvhb

I've no idea what this would even look like, bearing in mind that Swashbuckle generates API descriptions based on the information it can infer from static code constructs - routes, action signatures, attributes, class definitions, serializer configuration etc. I'm not aware of any such constructs that could be used to indicate which webhooks (if any) an action will support.

You could possibly go down the custom attribute approach, whereby you annotate operations with "documentation-specific" attributes to provide webhook metadata that can then be added to the generated document. Swashbuckle already has a Swashbuckle.AspNetCore.Annotations library that does something similar for other Swagger/OpenAPI metadata so extending this would be the approach I would recommend.

If someone on this thread is intereseted in taking on a POC, and putting together a proposed solution that would probably be the quickest approach to gaining traction on this feature?

domaindrivendev avatar Apr 03 '20 10:04 domaindrivendev

Hope this solution helps someone along until native support is added.

Assumptions:

  1. My API registers callbacks for different event types. These are always enum values in my system.
  2. Create SwaggerCallbackSchemaAttribute that I apply to each enum value to indicate the callback payload schema for that event type
  3. Operation filter that constructs the basic callback structure by reflection

Attribute

    [AttributeUsage(AttributeTargets.Field)]
    public class SwaggerCallbackSchemaAttribute : Attribute
    {
        public Type SchemaType { get; }

        public SwaggerCallbackSchemaAttribute(Type schemaType)
        {
            SchemaType = schemaType;
        }
    }

Operation filter

public class WebhookCallbacksOperationFilter : IOperationFilter
    {
        private readonly OpenApiResponses defaultCallbackResponses = new OpenApiResponses()
        {
            {"200", new OpenApiResponse(){Description = "Your server implementation should return this HTTP status code if the data was received successfully"}},
            {"4xx", new OpenApiResponse(){Description = "If your server returns an HTTP status code indicating it does not understand the format of the payload the delivery will be treated as a failure. No retries are attempted."}},
            {"5xx", new OpenApiResponse(){Description = "If your server returns an HTTP status code indicating a server-side error the delivery will be treated as a failure. No retries are attempted."}},
        };
        
        private class Callback
        {
            public string Name { get; set; }

            public OpenApiCallback CallbackObj { get; set; }
        }

        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {            
            // Search the parameters of this method for any properties that may have the SwaggerCallbackSchemaAttribute applied
            // assign that field name to the callback section
            foreach (var parameterInfo in context.MethodInfo.GetParameters())
            {
                foreach (var callback in FindCallbacks(parameterInfo, context))
                {
                    operation.Callbacks.Add(callback.Name, callback.CallbackObj);
                }
            }
        }

        private IEnumerable<Callback> FindCallbacks(ParameterInfo parameterInfo, OperationFilterContext context)
        {
            // Iterate through all of the Properties on the parameter and see if any of their types contain the attribute
            foreach (var propInfo in parameterInfo.ParameterType.GetProperties())
            {
                if ((!propInfo.PropertyType.IsEnum )
                {
                    continue;
                }

                var enumType = propInfo.PropertyType;
                foreach (var pmInfo in enumType.GetMembers(BindingFlags.Public | BindingFlags.Static))
                {
                    var callbackAttrib = pmInfo.GetCustomAttribute<SwaggerCallbackSchemaAttribute>();
                    if (callbackAttrib == null)
                    {
                        continue;
                    }

                    var obsoleteAttrib = pmInfo.GetCustomAttribute<ObsoleteAttribute>();
                    var enumMemberAttrib = pmInfo.GetCustomAttribute<EnumMemberAttribute>();
                    var enumValue = enumMemberAttrib != null && !string.IsNullOrEmpty(enumMemberAttrib.Value) ? enumMemberAttrib.Value : pmInfo.Name;

                    var callbackObjRequest = new OpenApiRequestBody();
                    callbackObjRequest.Content.Add(
                        "application/json", new OpenApiMediaType()
                        {
                            Schema = new OpenApiSchema()
                            {
                                Reference = new OpenApiReference()
                                {
                                    Id = callbackAttrib.SchemaType.Name,
                                    Type = ReferenceType.Schema
                                }
                            }
                        }
                    );

                    var callbackObj = new OpenApiPathItem();
                    callbackObj.Operations.Add(
                        OperationType.Post, new OpenApiOperation()
                        {
                            Summary = enumValue,
                            RequestBody = callbackObjRequest,
                            Deprecated = obsoleteAttrib != null,
                            Responses = defaultCallbackResponses
                        }
                    );

                    yield return new Callback()
                    {
                        Name = enumValue,
                        CallbackObj = new OpenApiCallback()
                        {
                            PathItems = new Dictionary<RuntimeExpression, OpenApiPathItem>()
                            {
                                {
                                    RuntimeExpression.Build("https://YOUR-SERVER-URL/"), callbackObj
                                }
                            }
                        }
                    };
                }
            }
        }
    }

Usage

public enum WebhookEventType
    {       
        [EnumMember(Value = "send.payload")]
        [SwaggerCallbackSchema(typeof(WebhookPayload))]
        SendPayload
}

sverrirs avatar May 26 '20 15:05 sverrirs

Thanks Sverrirs. Would you be able to post a link (or at least a screen capture) so we can see what the generated webhook entry looks like in the API docs?

briansboyd avatar Jun 30 '20 22:06 briansboyd

Is someone doing a PR on this?

knom avatar Jul 01 '20 08:07 knom

I have implemented Sverrirs suggested fix to this problem but I now get the error Could not resolve reference: Could not resolve pointer: /components/schemas/WebhookPayload does not exist in document

Am I missing something?

My WebhookPayload class is public class WebhookPayload { public Guid OrderId { get; set; } public string OrderNumber { get; set; } }

And my webhook registration Model is public class Webhook { public WebhookEventType EventType { get; set; } public string WebhookUri { get; set; } public bool Enabled { get; set; } public string Secret { get; set; } }

Swagger is outputting the POST endpoint and including the Callback tab but the example is blank.

Any help would be greatly appreciated

danielgradwell avatar Jan 23 '21 13:01 danielgradwell

@sverrirs I have (slightly) improved upon your code and built a nuget library to do this. The package is here:

https://www.nuget.org/packages/Rocklan.SwaggerGen.Callbacks

Code is in github here:

https://github.com/rocklan/Rocklan.SwaggerGen.Callbacks

If anyone would like to contribute that would be great, or if @domaindrivendev would like to suggest a better approach or for it to be merged somewhere then I'm all ears.

rocklan avatar Jul 19 '21 02:07 rocklan

@danielgradwell you're seeing this error, because generated schemas in swagger.json does not include definition for WebhookPayload.

You could use something like custom document filter to explicitly add WebhookPayload definition to the document.

prostakov avatar Nov 18 '21 17:11 prostakov

So I took a bit of a different approach. I needed the ability to specify which callback would be executed for a given operation without a way of determining this from the request body. I did this using an Attribute on the controller Action and a custom operation filter. Below is my take on this.

WebApi Controller Action

        [HttpPost]
        [Consumes(MediaTypeNames.Application.Json)]
        [SwaggerResponse(201, "Created", typeof(MyResponseModel), MediaTypeNames.Application.Json)]
        [SwaggerCallbackSchema(WebhookEventType.EventTypeOne, typeof(CallBackPayloadDataDTO))]
        [SwaggerCallbackSchema(WebhookEventType.EventTypeTwo, typeof(CallBackPayloadDataDTO))]
        [Authorize]
        public async Task<IActionResult> Post(MyRequestModel request, CancellationToken cancellationToken)
        {
              // Your code here
              return Ok();
        }

Attribute

    /// <summary>
    /// Add this attribute to an action method to indicate the action (operation) publishes an callback. 
    /// </summary>
    [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
    public class SwaggerCallbackSchemaAttribute : Attribute
    {
        /// <summary>
        /// Unique type (eg, an enum's property) of this callback
        /// </summary>
        public Type SchemaType { get; }

        /// <summary>
        /// Example URL to show on the swagger page
        /// </summary>
        public string Name { get; }

        /// <summary>
        /// Registers a Callback. 
        /// </summary>
        /// <param name="name">A string or Enum to use as the name.</param>
        /// <param name="schemaType">The type of object being returned.</param>
        /// <exception cref="ArgumentNullException"></exception>
        public SwaggerCallbackSchemaAttribute(object name, Type schemaType)
        {
            if (name == null) throw new ArgumentNullException(nameof(name));
            if (schemaType == null) throw new ArgumentNullException(nameof(schemaType));

            Name = GetName(name);
            SchemaType = schemaType;
        }

        private static string GetName(object name)
        {
            if (name is string sName)
            {
                return sName;
            }
            else if (name is Enum eName)
            {
                return FromEnum(eName);
            }
            throw new ArgumentException($"{name.GetType().FullName} is not supported");
        }

        private static string FromEnum(Enum value)
        {
            var member = value.GetType().GetMember(value.ToString(), 
                MemberTypes.Field, BindingFlags.Public | BindingFlags.Static).FirstOrDefault();
            if (member == null) return value.ToString();

            var attribute = member.GetCustomAttributes(false).OfType<EnumMemberAttribute>().FirstOrDefault();
            if (attribute == null) return value.ToString();
            return attribute.Value;
        }
    }

Operation Filter

    /// <summary>
    /// Adds support for callbacks to the swagger doc
    /// </summary>
    public class CallbacksOperationFilter : IOperationFilter
    {
        private static readonly OpenApiResponses _defaultCallbackResponses = new OpenApiResponses()
        {
            {"200", new OpenApiResponse(){
                Description = "Your server implementation should return this HTTP status " +
                "code if the data was received successfully"}},
            {"4xx", new OpenApiResponse(){
                Description = "If your server returns an HTTP status code indicating " +
                "it does not understand the format of the payload the delivery will be treated as a failure. No retries are attempted."}},
            {"5xx", new OpenApiResponse(){
                Description = "If your server returns an HTTP status code indicating " +
                "a server-side error the delivery will be treated as a failure. retries are attempted."}},
        };

        private readonly XPathNavigator _xmlNavigator;

        public CallbacksOperationFilter(XPathDocument xmlDocument)
        {
            _xmlNavigator = xmlDocument.CreateNavigator();
        }

        private class Callback
        {
            public string Name { get; set; }

            public OpenApiCallback CallbackObj { get; set; }
        }

        /// <summary>
        /// Modifies the operations section of the swagger page to add callbacks
        /// </summary>
        /// <param name="operation"></param>
        /// <param name="context"></param>
        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {
            foreach (var attribute in context.MethodInfo
                .GetCustomAttributes<SwaggerCallbackSchemaAttribute>())
            {
                context.SchemaGenerator.GenerateSchema(attribute.SchemaType, context.SchemaRepository);

                var callback = CreateCallback(attribute);
                operation.Callbacks.Add(callback.Name, callback.CallbackObj);
            }
        }

        private Callback CreateCallback(SwaggerCallbackSchemaAttribute attribute)
        {
            var obsoleteAttrib = attribute.SchemaType.GetCustomAttribute<ObsoleteAttribute>();
            
            var (summary, description) = GetContent(attribute);

            var callbackObj = new OpenApiPathItem()
            {
                Operations = new Dictionary<OperationType, OpenApiOperation>()
                {
                    { OperationType.Post, new OpenApiOperation()
                        {
                            Summary = summary,
                            Description = description,
                            RequestBody = new OpenApiRequestBody()
                            {
                                Content = new Dictionary<string, OpenApiMediaType>()
                                {
                                    { "application/json", new OpenApiMediaType()
                                        {
                                            Schema = new OpenApiSchema()
                                            {
                                                Reference = new OpenApiReference()
                                                {
                                                    Id = attribute.SchemaType.Name,
                                                    Type = ReferenceType.Schema
                                                }
                                            }
                                        }
                                    }
                                }
                            },
                            Deprecated = obsoleteAttrib != null,
                            Responses = _defaultCallbackResponses
                        }
                    }
                }
            };

            return new Callback()
            {
                Name = attribute.Name,
                CallbackObj = new OpenApiCallback()
                {
                    PathItems = new Dictionary<RuntimeExpression, OpenApiPathItem>()
                    {
                        {
                            RuntimeExpression.Build("http://www.thirdparty.com/webhook_endpoint"), callbackObj
                        }
                    }
                }
            };
        }

        private (string summary, string description) GetContent(SwaggerCallbackSchemaAttribute attribute)
        {
            // ref: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/v6.3.1/src/Swashbuckle.AspNetCore.SwaggerGen/XmlComments/XmlCommentsOperationFilter.cs#L39-L56
            var name = XmlCommentsNodeNameHelper.GetMemberNameForType(attribute.SchemaType);
            var typeNode = _xmlNavigator.SelectSingleNode($"/doc/members/member[@name='{name}']");

            string summary = null;
            string description = null;
            if (typeNode != null)
            {
                var summaryNode = typeNode.SelectSingleNode("summary");
                if (summaryNode != null)
                {
                    summary = XmlCommentsTextHelper.Humanize(summaryNode.InnerXml);
                }

                var remarksNode = typeNode.SelectSingleNode("remarks");
                if (remarksNode != null)
                {
                    description = XmlCommentsTextHelper.Humanize(remarksNode.InnerXml);
                }
            }

            return (summary, description);
        }
    }


MattMinke avatar Jun 22 '22 20:06 MattMinke

The question was asked and then deleted as to how I instantiated the XPathDocument in my DI. This is what I did. There are probably better ways to do this but it works for my needs.

services.AddSwaggerGen(c =>
{

    c.OperationFilter<CallbacksOperationFilter>(new XPathDocument(Path.Combine(System.AppContext.BaseDirectory, "MyProject.Domain.WebApi.xml")));
    c.IncludeXmlComments(Path.Combine(System.AppContext.BaseDirectory, "MyProject.Domain.WebApi.xml"));
    c.IncludeXmlComments(Path.Combine(System.AppContext.BaseDirectory, "MyProject.Common.xml"));
 
    // Remainder of the configuration for Swagger.    
    //  ......
}

MattMinke avatar Feb 22 '23 16:02 MattMinke

Sorry yea I deleted it cause we aren't using xml comments which is something I didn't want to have to add - so I just used the attribute properties to determine what should be displayed in the schema. Thanks for posting though!

mycarrysun avatar Feb 22 '23 17:02 mycarrysun

Maybe there could be a method to add this like?

builder.Services.AddSwaggerGen(c =>
{
    c.AddWebhook<PaymentNotification>("application/json");
    c.AddWebhook<PullRequestMergedNotification>("application/x-www-form-urlencoded");
}

It would need to document things such as the model send through POST, the content type, any headers (such as "Signature"), any query string parameters (such as "secret").

vanillajonathan avatar Jun 15 '23 14:06 vanillajonathan

Any updates on this issue? I'm also interested in that. I don't think this is a low priority issue

g-barbosa avatar Aug 26 '24 18:08 g-barbosa

It should be possible to implement this in Swashbuckle because the OpenAPI specification includes callbacks. https://swagger.io/docs/specification/callbacks/

vanillajonathan avatar Aug 26 '24 20:08 vanillajonathan

Might be worth having a read of this https://github.com/rocklan/Rocklan.SwaggerGen.Callbacks haven’t tested it myself but looks very positive.

danielgradwell avatar Aug 26 '24 20:08 danielgradwell