Skip to content

Working with a Headless approach

Simon Yohannes edited this page Nov 14, 2023 · 10 revisions

Puck is pretty flexible, you don't need to use razor views if you don't wan't to. You could instead return JSON of the current page or other queried content.

By default, there is a catch-all endpoint mapped to the HomeController Index action. The code for this mapping is in Startup.cs:

endpoints.MapControllerRoute(
   name: "default",
   pattern: "{**path}"
   ,defaults: new { controller = "Home", action = "Index"}
);

HomeController inherits from puck.core.Controllers.BaseController and if you look at the Index action:

public IActionResult Index()
{
    return base.Puck();
}

all it does is call the inherited Puck action which will get the current page and render a view. if you wanted to go headless, you could just return the current page as JSON:

public IActionResult Index()
{
    var currentPage = QueryHelper<BaseModel>.Current();
    return Json(currentPage);
}

this is a simple example but in a real world scenario you'll likey want to use the QueryHelper to get multiple pieces of content and compose them into a wrapper class that contains your ViewModel and any other required data to return to the client. you'll also likely be calling Puck from another app, something like a single page app which contains your views or a mobile app and in this scenario you can set up your Puck project to allow cross origin requests. then your single page app hosted on a different domain can make requests to Puck and get the JSON responses.

query endpoint for returning multiple results, graphql style

the previous example showed you how to get Puck to return the requested page as JSON. what if you wanted to get back multiple results by search query? to set up a query endpoint, you need a controller which inherits from puck.core.Controllers.BaseController - you could use HomeController, for example. then you can create a Query action:

[Route("Query")]
[HttpPost]
public IActionResult Query([FromBody]List<puck.core.Models.QueryModel> queries,string cacheKey=null,int cacheMinutes=0) {
    return Json(base.Query(queries,cacheKey:cacheKey,cacheMinutes:cacheMinutes));
}

you can then post JSON requests to this endpoint (you will need to set the Content-Type header on your request to application/json). here's an example JSON request body:

[
    {
        "Type": "School",
        "Query": "NodeName:\"Moordale School\"",
        "Skip": 0,
        "Take": 100,
        "Sorts":"NodeName:desc,SortOrder:asc",
        "Include":[
             ["People.Students","Friends"],
             ["GalleryImages"]
        ],
        "Fields":["People","GalleryImages","NodeName"]
    },
    {
        "Type": "Page",
        "Query": "+Type:Page PuckGeoM:Location.LongLat,-0.1277582,51.5073509,10,asc",
        "Skip": 0,
        "Take": 100,
        "Sorts":"",
        "Include": []
    }
]

the above request body specifies two searches. each search consists of a Type - which is the type parameter that you would normally pass to QueryHelper. the Query parameter is a Lucene query, read this for reference. use Skip and Take for pagination and Sorts accepts a comma separated string of Field:asc or Field:desc, e.g. "SortOrder:desc,NodeName:asc". the "Fields" property allows you to specify which fields are returned, if not present, all fields will be returned.

the Include argument allows you to include any referenced ViewModels from Content Picker or Image Picker fields. these are fields of type List<PuckReference>. in the first query, you are specifying that for each School ViewModel in the result, include the ViewModels referenced in the People.Students property, which means that property will go from being a List<PuckReference> to List<TViewModel> - a list of referenced ViewModels. since include is recursive, the query above also specifies a recursive include of Friends, so for each Student in the People.Students list, their friends will be loaded too. the next include is GalleryImages and this will apply for each School in the result, loading in any referenced images.

it's also worth noting that Include also supports Lists, so if you have a list of a complex type like Person with a Content Picker property Friends, you can Include like so; Include:[["People.Friends"]] and it will actually loop through each person in the People list property and load their friends in.

this type of querying is very powerful but beware that it can be inefficient to pre-emptively include many-levels-deep worth of ViewModels. you may prefer to lazily load in referenced ViewModels by making separate requests to the Query action when those ViewModels are required. you may also want to specify CacheKey and CacheMinutes arguments when calling the Query action so that results can be cached.

the equivalent of QueryHelper's ExplicitType is specifying the type, like so; "+Type:Page".

the syntax for Geo/Spatial queries is PuckGeoM:Location.LongLat,-0.1277582,51.5073509,10,asc when the distance is specified in Miles and PuckGeoK:Location.LongLat,-0.1277582,51.5073509,10,asc when specified in kilometres. the parameters are passed in comma separated with no spaces, the parameters being the Field Name,Longitude,Latitude,Distance and finally the Sort. specify asc,desc or null for Sort values.

the results for these two queries will be returned in the format List<QueryResult> where each QueryResult contains a Results property which is a list of ViewModels and a Total property, which is the total hits - this is useful for pagination.

performing Interface searches with your Query endpoint

one of the handy features of the QueryHelper is that you can search for ViewModels that implement a particular interface or multiple interfaces. you can do this kind of search with your query endpoint by specifying the Implements property:

[
    {
        "Type": "BaseModel",
        "Query": "+Title:News",
        "Skip": 0,
        "Take": 100,
        "Implements":"IMainContent,IGalleryImages",
        "Sorts":"SortOrder:asc",
        "Include":[]
    }
]

above, you are specifying that you want ViewModels which implement the IMainContent and IGalleryImages interfaces. for Puck to be able to work with your interfaces, you need your interfaces to inherit from the base interface puck.core.Abstract.I_BaseModel.

image similarity search

[
    {
        "Type": "ImageVM",
        "Query": "",
        "Skip": 0,
        "Take": 100,
        "Implements":"",
        "Sorts":"",
        "Include":[],
        "Similar":"84f9d72e-63a1-43cb-b7c0-7b53cdf13e59,en-gb"
    }
]

advanced patterns for headless sites

as mentioned earlier, it's simple enough to get Puck to return the current page ViewModel as JSON. what will likely happen though, is that you will want to return related/additional data with your ViewModel. for example, you may want to return all CarouselImages with the Homepage ViewModel to display a carousel on your homepage. with the graphql style Query Endpoint detailed in the previous section, it would be simple enough to Include the CarouselImages property on the Homepage ViewModel to make sure all carousel image ViewModels linked to your Homepage are returned with it.

thing is, with the Query Endpoint, you have to know the Type you're querying and which properties to include upfront, this isn't always realistic. assuming we're working with the initial pattern shown on this page where Puck returns the current page as JSON, what would be ideal, is that if every ViewModel knew what additional data it needed and could return a Wrapped version of itself, containing itself (the ViewModel, i.e the Homepage) and any additional required data. fortunately, with a little polymorphism, this is easy to achieve.

the first thing you need to do, is in your ViewModels folder, create a BasePage which all of your ViewModels will inherit from.

public class BasePage:puck.core.Base.BaseModel
    {
        [Display(Name ="Meta Title",GroupName ="Meta")]
        public string MetaTitle { get; set; }
        
        [Display(Name = "Meta Description", GroupName = "Meta")]
        [UIHint(EditorTemplates.TextArea)]
        public string MetaDescription { get; set; }

        [Display(Name = "Page Title", GroupName = "Content")]
        public string PageTitle { get; set; }


        public virtual BasePageWrapper GetWrappedViewModel() {
            return new BasePageWrapper { ViewModel = this };
        }
    }

as you can see above, the BasePage inherits from Puck's BaseModel and has some Meta properties that all web pages should have as well as the PageTitle. you may have additional requirements which you can add. importantly, notice that it has a virtual method GetWrappedViewModel which returns a BasePageWrapper (which has a single property, the current ViewModel). the idea is that, for every ViewModel you create which inherits from this BasePage, you override the GetWrappedViewModel method and return a Wrapper model based on that particular ViewModel. here's an example of Homepage using this pattern:

public class Homepage:BasePage
    {
        [Display(GroupName = "Content")]
        [UIHint(EditorTemplates.RichText)]
        public string Bio { get; set; }

        [Display(Name ="Profile Picture",GroupName ="Content")]
        [UIHint(EditorTemplates.ImagePicker)]
        public List<PuckReference> ProfilePicture { get; set; }

        [Display(GroupName="Content")]
        [UIHint(EditorTemplates.ListEditor)]
        public List<SocialLink> Links { get; set; }

        public override BasePageWrapper GetWrappedViewModel()
        {
            var model = new HomepageWrapper { ViewModel = this};

            if (ProfilePicture != null && ProfilePicture.Any()) {
                var picture = ProfilePicture.GetAll<ImageVM>()?.FirstOrDefault();
                if (picture != null) {
                    model.ProfilePicture = picture;
                }
            }

            return model;
        }
    }

above is the homepage ViewModel, notice how it inherits from BasePage. the interesting part is where it overrides GetWrappedViewModel. notice that the model being returned by this method is not a BasePageWrapper as stated in the method signature but actually a HomepageWrapper - which, crucially, inherits from BasePageWrapper so can be boxed to that type and has an additional property, ProfilePicture which is not on the BasePageWrapper. the idea is that you have a different wrapper type for each type of ViewModel which inherits from BasePage but has properties related to that specific ViewModel. you can put your Wrapper models in a Models folder (not your ViewModels folder - you may have to create this Models folder).

now in the HomeController, your index action looks like this:

public IActionResult Index()
        {
            var viewModel = puck.core.Helpers.QueryHelper<BasePage>.Current();
            var wrapped = viewModel?.GetWrappedViewModel();
            return Json(wrapped);
        }

notice above that you query for the current page, cast to your BasePage type and then call GetWrappedViewModel on it. this will return the specific wrapper for whatever type of ViewModel the current page is. along with using the graphql style query endpoint from the previous section, this is a flexible and powerful approach for tackling most situations in headless websites.

note, to get this working, you must use JSON.net as your JSON serializer as the default one doesn't handle polymorphism. you will need to install the required package - Install-Package Microsoft.AspNetCore.Mvc.NewtonsoftJson. you will then need to modify your ConfigureServices method in your Startup.cs file.

the following:

services.AddControllersWithViews()
                .AddApplicationPart(typeof(puck.core.Controllers.BaseController).Assembly)
                .AddControllersAsServices()
                .AddRazorRuntimeCompilation()
                .AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = null);

needs to be replaced with this:

services.AddControllersWithViews()
                .AddApplicationPart(typeof(puck.core.Controllers.BaseController).Assembly)
                .AddControllersAsServices()
                .AddRazorRuntimeCompilation()
                .AddNewtonsoftJson(x => {
                    x.UseMemberCasing();
                })
                .AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = null);