Welcome to FluentCMS! 🚀
FluentCMS makes content management seamless with its powerful GraphQL API and intuitive drag-and-drop page design features.
If you'd like to contribute, please check out our CONTRIBUTING guide.
Enjoying FluentCMS? Don’t forget to give us a ⭐ and help us grow!
Fluent CMS is an open-source Content Management System designed to simplify and accelerate web development workflows. While it's particularly suited for CMS projects, it is also highly beneficial for general web applications, reducing the need for repetitive REST/GraphQL API development.
-
Effortless CRUD Operations: Fluent CMS includes built-in RESTful APIs for Create, Read, Update, and Delete (CRUD) operations, complemented by a React-based admin panel for efficient data management.
-
Powerful GraphQL Queries: Access multiple related entities in a single query, enhancing client-side performance, security, and flexibility.
-
Drag-and-Drop Page Designer: Build dynamic pages effortlessly using the integrated page designer powered by Grapes.js and Handlebars. Easily bind data sources for an interactive and streamlined design experience.
Example Blog Project on GitHub
- Public Site: fluent-cms-admin.azurewebsites.net
- Admin Panel: fluent-cms-admin.azurewebsites.net/admin
- Email:
[email protected]
- Password:
Admin1!
- Email:
- GraphQL Playground: fluent-cms-admin.azurewebsites.net/graph
- Documentation: fluent-cms-admin.azurewebsites.net/doc/index.html
This section provides detailed guidance on developing a foundational online course system, encompassing key entities: `teacher`, `course`, `skill`, and `material`.
The Teachers
table maintains information about instructors, including their personal and professional details.
Field | Header | Data Type |
---|---|---|
id |
ID | Int |
firstname |
First Name | String |
lastname |
Last Name | String |
email |
String | |
phone_number |
Phone Number | String |
image |
Image | String |
bio |
Bio | Text |
The Courses
table captures the details of educational offerings, including their scope, duration, and prerequisites.
Field | Header | Data Type |
---|---|---|
id |
ID | Int |
name |
Course Name | String |
status |
Status | String |
level |
Level | String |
summary |
Summary | String |
image |
Image | String |
desc |
Description | Text |
duration |
Duration | String |
start_date |
Start Date | Datetime |
The Skills
table records competencies attributed to teachers.
Field | Header | Data Type |
---|---|---|
id |
ID | Int |
name |
Skill Name | String |
years |
Years of Experience | Int |
created_by |
Created By | String |
created_at |
Created At | Datetime |
updated_at |
Updated At | Datetime |
The Materials
table inventories resources linked to courses.
Field | Header | Data Type |
---|---|---|
id |
ID | Int |
name |
Name | String |
type |
Type | String |
image |
Image | String |
link |
Link | String |
file |
File | String |
- Teachers to Courses: One-to-Many (Each teacher can teach multiple courses; each course is assigned to one teacher).
- Teachers to Skills: Many-to-Many (Multiple teachers can share skills, and one teacher may have multiple skills).
- Courses to Materials: Many-to-Many (A course may include multiple materials, and the same material can be used in different courses).
After launching the web application, locate the Schema Builder menu on the homepage to start defining your schema.
- Navigate to the Entities section of the Schema Builder.
- Create entities such as "Teacher" and "Course."
- For the
Course
entity, add attributes such asname
,status
,level
, anddescription
.
To establish a many-to-one relationship between the Course
and Teacher
entities, you can include a Lookup
attribute in the Course
entity. This allows selecting a single Teacher
record when adding or updating a Course
.
Attribute | Value |
---|---|
Field | teacher |
Type | Lookup |
Options | Teacher |
Description: When a course is created or modified, a teacher record can be looked up and linked to the course.
To establish a many-to-many relationship between the Course
and Material
entities, use a Junction
attribute in the Course
entity. This enables associating multiple materials with a single course.
Attribute | Value |
---|---|
Field | materials |
Type | Junction |
Options | Material |
Description: When managing a course, you can select multiple material records from the Material
table to associate with the course.
Example Course List Page
The List Page displays entities in a tabular format, enabling sorting, searching, and pagination. Users can efficiently browse or locate specific records.
Example Course Detail Page
The Detail Page provides an interface for viewing and managing detailed attributes. Related data such as teachers and materials can be selected or modified.
FluentCMS simplifies frontend development by offering robust GraphQL support.
To get started, launch the web application and navigate to /graph
. You can also try our online demo.
For each entity in FluentCMS, two GraphQL fields are automatically generated:
<entityName>
: Returns a record.<entityNameList>
: Returns a list of records.
**Single Course **
{
course {
id
name
}
}
**List of Courses **
{
courseList {
id
name
}
}
You can query specific fields for both the current entity and its related entities. Example Query:
{
courseList{
id
name
teacher{
id
firstname
lastname
skills{
id
name
}
}
materials{
id,
name
}
}
}
FluentCMS provides flexible filtering capabilities using the idSet
field (or any other field), enabling precise data queries by matching either a single value or a list of values.
Filter by a Single Value Example:
{
courseList(idSet: 5) {
id
name
}
}
Filter by Multiple Values Example:
{
courseList(idSet: [5, 7]) {
id
name
}
}
FluentCMS supports advanced filtering options with Operator Match
, allowing users to combine various conditions for precise queries.
Filters where all specified conditions must be true.
In this example: id > 5 and id < 15
.
{
courseList(id: {matchType: matchAll, gt: 5, lt: 15}) {
id
name
}
}
Filters where at least one of the conditions must be true.
In this example: name starts with "A"
or name starts with "I"
.
{
courseList(name: [{matchType: matchAny}, {startsWith: "A"}, {startsWith: "I"}]) {
id
name
}
}
Filter Expressions allow precise filtering by specifying a field, including nested fields using JSON path syntax. This enables filtering on subfields for complex data structures.
Example: Filter by Teacher's Last Name This query returns courses taught by a teacher whose last name is "Circuit."
{
courseList(filterExpr: {field: "teacher.lastname", clause: {equals: "Circuit"}}) {
id
name
teacher {
id
lastname
}
}
}
Sorting by a single field
{
courseList(sort:nameDesc){
id,
name
}
}
Sorting by multiple fields
{
courseList(sort:[level,id]){
id,
level
name
}
}
Sort Expressions allow sorting by nested fields using JSON path syntax.
Example: Sort by Teacher's Last Name
{
courseList(sortExpr:{field:"teacher.lastname", order:Desc}) {
id
name
teacher {
id
lastname
}
}
}
Pagination on root field
{
courseList(offset:2, limit:3){
id,
name
}
}
Try it here
Pagination on sub field
{
courseList{
id,
name
materials(limit:2){
id,
name
}
}
}
Variables are used to make queries more dynamic, reusable, and secure.
query ($id: Int!) {
teacher(idSet: [$id]) {
id
firstname
lastname
}
}
query ($id: Int!) {
teacherList(id:{equals:$id}){
id
firstname
lastname
}
}
query ($years: String) {
teacherList(filterExpr:{field:"skills.years",clause:{gt:$years}}){
id
firstname
lastname
skills{
id
name
years
}
}
}
query ($sort_field:TeacherSortEnum) {
teacherList(sort:[$sort_field]) {
id
firstname
lastname
}
}
query ($sort_order: SortOrderEnum) {
courseList(sortExpr:{field:"teacher.id", order:$sort_order}){
id,
name,
teacher{
id,
firstname
}
}
}
query ($offset:Int) {
teacherList(limit:2, offset:$offset) {
id
firstname
lastname
}
}
If you want a variable to be mandatory, you can add a !
to the end of the type
query ($id: Int!) {
teacherList(id:{equals:$id}){
id
firstname
lastname
}
}
Explore the power of FluentCMS GraphQL and streamline your development workflow!
Realtime queries may expose excessive technical details, potentially leading to security vulnerabilities.
Saved Queries address this issue by abstracting the GraphQL query details. They allow clients to provide only variables, enhancing security while retaining full functionality.
In FluentCMS, the Operation Name in a GraphQL query serves as a unique identifier for saved queries. For instance, executing the following query automatically saves it as TeacherQuery
:
query TeacherQuery($id: Int) {
teacherList(idSet: [$id]) {
id
firstname
lastname
skills {
id
name
}
}
}
FluentCMS generates two API endpoints for each saved query:
-
List Records:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery -
Single Record:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery/one/
The Saved Query API allows passing variables via query strings:
-
Single Value:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery/?id=3 -
Array of Values:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?id=3&id=4
This passes[3, 4]
to theidSet
argument.
Beyond performance and security improvements, Saved Query
introduces enhanced functionalities to simplify development workflows.
Built-in variables offset
and limit
enable efficient pagination. For example:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=2&offset=2
To display a limited number of subfield items (e.g., the first two skills of a teacher), use the JSON path variable, such as skills.limit
:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?skills.limit=2
For large datasets, offset
pagination can strain the database. For example, querying with offset=1000&limit=10
forces the database to retrieve 1010 records and discard the first 1000.
To address this, Saved Query
supports cursor-based pagination, which reduces database overhead.
Example response for https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=3:
[
{
"hasPreviousPage": false,
"cursor": "eyJpZCI6M30"
},
{
},
{
"hasNextPage": true,
"cursor": "eyJpZCI6NX0"
}
]
-
If
hasNextPage
of the last record istrue
, use the cursor to retrieve the next page:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=3&last=eyJpZCI6NX0 -
Similarly, if
hasPreviousPage
of the first record istrue
, use the cursor to retrieve the previous page:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?limit=3&first=eyJpZCI6Nn0
Subfields also support cursor-based pagination. For instance, querying https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery?skills.limit=2 returns a response like this:
[
{
"id": 3,
"firstname": "Jane",
"lastname": "Debuggins",
"hasPreviousPage": false,
"skills": [
{
"hasPreviousPage": false,
"cursor": "eyJpZCI6MSwic291cmNlSWQiOjN9"
},
{
"hasNextPage": true,
"cursor": "eyJpZCI6Miwic291cmNlSWQiOjN9"
}
],
"cursor": "eyJpZCI6M30"
}
]
To fetch the next two skills, use the cursor:
https://fluent-cms-admin.azurewebsites.net/api/queries/TeacherQuery/part/skills?limit=2&last=eyJpZCI6Miwic291cmNlSWQiOjN9
The page designer utilizes the open-source GrapesJS and Handlebars, enabling seamless binding of `GrapesJS Components` with `FluentCMS Queries` for dynamic content rendering.
A landing page is typically the first page a visitor sees.
- URL format:
/page/<pagename>
- Structure: Comprised of multiple sections, each section retrieves data via a
query
.
Example:
Landing Page
This page fetches data from:
- https://fluent-cms-admin.azurewebsites.net/api/queries/course/?status=featured
- https://fluent-cms-admin.azurewebsites.net/api/queries/course/?level=Advanced
A detail page provides specific information about an item.
- URL format:
/page/<pagename>/<router parameter>
- Data Retrieval: FluentCMS fetches data by passing the router parameter to a
query
.
Example:
Course Detail Page
This page fetches data from:
https://fluent-cms-admin.azurewebsites.net/api/queries/course/one?course_id=22
The homepage is a special type of landing page named home
.
- URL format:
/pages/home
- Special Behavior: If no other route matches the path
/
, FluentCMS renders/pages/home
by default.
Example:
The URL /
will be resolved to /pages/home
unless explicitly overridden.
Understanding the panels in GrapesJS is crucial for leveraging FluentCMS's customization capabilities in the Page Designer UI. This section explains the purpose of each panel and highlights how FluentCMS enhances specific areas to streamline content management and page design.
-
Style Manager:
- Used to customize CSS properties of elements selected on the canvas.
- FluentCMS Integration: This panel is left unchanged by FluentCMS, as it already provides powerful styling options.
-
Traits Panel:
- Allows modification of attributes for selected elements.
- FluentCMS Integration: Custom traits are added to this panel, enabling users to bind data to components dynamically.
-
Layers Panel:
- Displays a hierarchical view of elements on the page, resembling a DOM tree.
- FluentCMS Integration: While FluentCMS does not alter this panel, it’s helpful for locating and managing FluentCMS blocks within complex page designs.
-
Blocks Panel:
- Contains pre-made components that can be dragged and dropped onto the page.
- FluentCMS Integration: FluentCMS enhances this panel by adding custom-designed blocks tailored for its CMS functionality.
By familiarizing users with these panels and their integration points, this chapter ensures a smoother workflow and better utilization of FluentCMS's advanced page-building tools.
FluentCMS leverages Handlebars expressions for dynamic data binding in pages and components.
Singleton fields are enclosed within {{ }}
to dynamically bind individual values.
- Example Page Settings: Page Schema Settings
- Example Query: Retrieve Course Data
- Example Rendered Page: Rendered Course Page
Handlebars
supports iterating over arrays using the {{#each}}
block for repeating data structures.
In FluentCMS, you won’t explicitly see the {{#each}}
statement in the Page Designer. If a block's data source is set to data-list
, FluentCMS automatically generates the loop.
- Example Page Settings: Page Schema Settings
- Example Rendered Page: Rendered List Page
- Example Queries:
To bind a Data List
to a component, follow these steps:
- Drag a block from the Data List category in the Page Designer.
- Open the Layers Panel and select the
Data List
component. - In the Traits Panel, configure the following fields:
Field | Description |
---|---|
Query | The query to retrieve data. |
Qs | Query string parameters to pass (e.g., ?status=featured , ?level=Advanced ). |
Offset | Number of records to skip. |
Limit | Number of records to retrieve. |
Pagination | Options for displaying content: |
- Button: Divides content into multiple pages with navigation buttons (e.g., "Next," "Previous," or numbered buttons). | |
- Infinite Scroll: Automatically loads more content as users scroll. Ideal for a single component at the bottom of the page. | |
- None: Displays all available content at once without requiring additional user actions. |
Having established our understanding of Fluent CMS essentials like Entity, Query, and Page, we're ready to build a frontend for an online course website.
- Home Page (
home
): The main entry point, featuring sections like Featured Courses and Advanced Courses. Each course links to its respective Course Details page. - Course Details (
course/{course_id}
): Offers detailed information about a specific course and includes links to the Teacher Details page. - Teacher Details (
teacher/{teacher_id}
): Highlights the instructor’s profile and includes a section displaying their latest courses, which link back to the Course Details page.
Home Page
|
|
+-------------------+
| |
v v
Latest Courses Course Details
| |
| |
v v
Course Details <-------> Teacher Details
- Drag and Drop Components: Use the Fluent CMS page designer to drag a
Content-B
component. - Set Data Source: Assign the component's data source to the
course
query. - Link Course Items: Configure the link for each course to
/pages/course/{{id}}
. The Handlebars expression{{id}}
is dynamically replaced with the actual course ID during rendering.
- Page Setup: Name the page
course/{course_id}
to capture thecourse_id
parameter from the URL (e.g.,/pages/course/20
). - Query Configuration: The variable
{course_id:20}
is passed to thecourse
query, generating aWHERE id IN (20)
clause to fetch the relevant course data. - Linking to Teacher Details: Configure the link for each teacher item on this page to
/pages/teacher/{{teacher.id}}
. Handlebars dynamically replaces{{teacher.id}}
with the teacher’s ID. For example, if a teacher object has an ID of 3, the link renders as/pages/teacher/3
.
- Page Setup: Define the page as
teacher/{teacher_id}
to capture theteacher_id
parameter from the URL. - Set Data Source: Assign the
teacher
query as the page’s data source.
- Drag a
ECommerce A
component onto the page. - Set its data source to the
course
query, filtered by the teacher’s ID (WHERE teacher IN (3)
).
When rendering the page, the PageService
automatically passes the teacher_id
(e.g., {teacher_id: 3}
) to the query.
Fluent CMS employs advanced caching strategies to boost performance.
For detailed information on ASP.NET Core caching, visit the official documentation: ASP.NET Core Caching Overview.
Fluent CMS automatically invalidates schema caches whenever schema changes are made. The schema cache consists of two types:
-
Entity Schema Cache
Caches all entity definitions required to dynamically generate GraphQL types. -
Query Schema Cache
Caches query definitions, including dependencies on multiple related entities, to compose efficient SQL queries.
By default, schema caching is implemented using IMemoryCache
. However, you can override this by providing a HybridCache
. Below is a comparison of the two options:
- Advantages:
- Simple to debug and deploy.
- Ideal for single-node web applications.
- Disadvantages:
- Not suitable for distributed environments. Cache invalidation on one node (e.g., Node A) does not propagate to other nodes (e.g., Node B).
- Key Features:
- Scalability: Combines the speed of local memory caching with the consistency of distributed caching.
- Stampede Resolution: Effectively handles cache stampede scenarios, as verified by its developers.
- Limitations:
The current implementation lacks "Backend-Assisted Local Cache Invalidation," meaning invalidation on one node does not instantly propagate to others. - Fluent CMS Strategy:
Fluent CMS mitigates this limitation by setting the local cache expiration to 20 seconds (one-third of the distributed cache expiration, which is set to 60 seconds). This ensures cache consistency across nodes within 20 seconds, significantly improving upon the typical 60-second delay in memory caching.
To implement a HybridCache
, use the following code:
builder.AddRedisDistributedCache(connectionName: CmsConstants.Redis);
builder.Services.AddHybridCache();
Fluent CMS does not automatically invalidate data caches. Instead, it leverages ASP.NET Core's output caching for a straightforward implementation. Data caching consists of two types:
-
Query Data Cache
Caches the results of queries for faster access. -
Page Cache
Caches the output of rendered pages for quick delivery.
By default, output caching is disabled in Fluent CMS. To enable it, configure and inject the output cache as shown below:
builder.Services.AddOutputCache(cacheOption =>
{
cacheOption.AddBasePolicy(policyBuilder => policyBuilder.Expire(TimeSpan.FromMinutes(1)));
cacheOption.AddPolicy(CmsOptions.DefaultPageCachePolicyName,
b => b.Expire(TimeSpan.FromMinutes(2)));
cacheOption.AddPolicy(CmsOptions.DefaultQueryCachePolicyName,
b => b.Expire(TimeSpan.FromSeconds(1)));
});
// After builder.Build();
app.UseOutputCache();
Fluent CMS leverages Aspire to simplify deployment.
A scalable deployment of Fluent CMS involves multiple web application nodes, a Redis server for distributed caching, and a database server, all behind a load balancer.
+------------------+
| Load Balancer |
+------------------+
|
+-----------------+-----------------+
| |
+------------------+ +------------------+
| Web App 1 | | Web App 2 |
| +-----------+ | | +-----------+ |
| | Local Cache| | | | Local Cache| |
+------------------+ +------------------+
| |
| |
+-----------------+-----------------+
| |
+------------------+ +------------------+
| Database Server | | Redis Server |
+------------------+ +------------------+
Example Web project on GitHub
Example Aspire project on GitHub
To emulate the production environment locally, Fluent CMS leverages Aspire. Here's an example setup:
var builder = DistributedApplication.CreateBuilder(args);
// Adding Redis and PostgreSQL services
var redis = builder.AddRedis(name: CmsConstants.Redis);
var db = builder.AddPostgres(CmsConstants.Postgres);
// Configuring the web project with replicas and references
builder.AddProject<Projects.FluentCMS_Blog>(name: "web")
.WithEnvironment(CmsConstants.DatabaseProvider, CmsConstants.Postgres)
.WithReference(redis)
.WithReference(db)
.WithReplicas(2);
builder.Build().Run();
- Simplified Configuration:
No need to manually specify endpoints for the database or Redis servers. Configuration values can be retrieved using:builder.Configuration.GetValue<string>(); builder.Configuration.GetConnectionString();
- Realistic Testing:
The local environment mirrors the production architecture, ensuring seamless transitions during deployment.
By adopting these caching and deployment strategies, Fluent CMS ensures improved performance, scalability, and ease of configuration.
Follow these steps to integrate Fluent CMS into your project using a NuGet package.
-
Create a New ASP.NET Core Web Application.
-
Add the FluentCMS NuGet Package: To add Fluent CMS, run the following command:
dotnet add package FluentCMS
-
Modify
Program.cs
: Add the following line beforebuilder.Build()
to configure the database connection (use your actual connection string):builder.AddSqliteCms("Data Source=cms.db"); var app = builder.Build();
Currently, Fluent CMS supports
AddSqliteCms
,AddSqlServerCms
, andAddPostgresCms
. -
Initialize Fluent CMS: Add this line after
builder.Build()
to initialize the CMS:await app.UseCmsAsync();
This will bootstrap the router and initialize the Fluent CMS schema table.
-
Optional: Set Up User Authorization: If you wish to manage user authorization, you can add the following code. If you're handling authorization yourself or don’t need it, you can skip this step.
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString)); builder.AddCmsAuth<IdentityUser, IdentityRole, AppDbContext>();
If you'd like to create a default user, add this after
app.Build()
:InvalidParamExceptionFactory.CheckResult(await app.EnsureCmsUser("[email protected]", "Admin1!", [Roles.Sa]));
Once your web server is running, you can access the Admin Panel at /admin
and the Schema Builder at /schema
.
You can find an example project here.
Learn how to customize your application by adding validation logic, hook functions, and producing events to Kafka.
You can define simple C# expressions in the Validation Rule
of attributes using Dynamic Expresso. For example, a rule like name != null
ensures the name
attribute is not null.
Additionally, you can specify a Validation Error Message
to provide users with feedback when validation fails.
Dynamic Expresso
supports regular expressions, allowing you to write rules like Regex.IsMatch(email, "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$")
.
Note: Since
Dynamic Expresso
doesn't support verbatim strings, you must escape backslashes (\
).
To implement custom business logic, such as verifying that a teacher
entity has valid email and phone details, you can register hook functions to run before adding or updating records:
var registry = app.GetHookRegistry();
// Hook function for pre-add validation
registry.EntityPreAdd.Register("teacher", args =>
{
VerifyTeacher(args.RefRecord);
return args;
});
// Hook function for pre-update validation
registry.EntityPreUpdate.Register("teacher", args =>
{
VerifyTeacher(args.RefRecord);
return args;
});
To enable asynchronous business logic through an event broker like Kafka, you can produce events using hook functions. This feature requires just a few additional setup steps:
-
Add the Kafka producer configuration:
builder.AddKafkaMessageProducer("localhost:9092");
-
Register the message producer hook:
app.RegisterMessageProducerHook();
Here’s a complete example:
builder.AddSqliteCms("Data Source=cmsapp.db").PrintVersion();
builder.AddKafkaMessageProducer("localhost:9092");
var app = builder.Build();
await app.UseCmsAsync(false);
app.RegisterMessageProducerHook();
With this setup, events are produced to Kafka, allowing consumers to process business logic asynchronously.
The backend is written in ASP.NET Core, the Admin Panel uses React, and the Schema Builder is developed with jQuery.
- Tools:
- ASP.NET Core
- SqlKata: SqlKata
- Tools:
- React
- PrimeReact: PrimeReact UI Library
- SWR: Data Fetching/State Management
- Tools:
- jsoneditor: JSON Editor
This chapter describes Fluent CMS's automated testing strategy
Fluent CMS favors integration testing over unit testing because integration tests can catch more real-world issues. For example, when inserting a record into the database, multiple modules are involved:
EntitiesController
EntitiesService
Entity
(in the query builder)- Query executors (e.g.,
SqlLite
,Postgres
,SqlServer
)
Writing unit tests for each individual function and mocking its upstream and downstream services can be tedious. Instead, Fluent CMS focuses on checking the input and output of RESTful API endpoints in its integration tests.
However, certain cases, such as the Hook Registry or application bootstrap, are simpler to cover with unit tests.
This project focuses on testing specific modules, such as:
- Hook Registry
- Application Bootstrap
This project focuses on verifying the functionalities of the FluentCMS.Blog example project.
This project is dedicated to testing experimental functionalities, like MongoDB and Kafka plugins.