Welcome to Fluent CMS!
If you'd like to contribute to the project, please check out our CONTRIBUTING guide.Don’t forget to give us a star ⭐ if you find Fluent CMS helpful!
Fluent CMS is an open-source Content Management System designed to streamline web development workflows.
It proves valuable even for non-CMS projects by eliminating the need for tedious CRUD API and page development.
- CRUD: Fluent CMS offers built-in RESTful CRUD (Create, Read, Update, Delete) APIs along with an Admin Panel that supports a wide range of input types, including datetime, dropdown, image, and rich text, all configurable to suit your needs.
- GraphQL-style Query Retrieve multiple related entities in a single call, enhancing security, performance, and flexibility on the client side.
- Drag and Drop Web Page Designer: Leveraging Grapes.js and HandleBars, the page designer allows you to create pages and bind query data without coding.
- Permission Control Assign read/write, read-only, access to entities based on user roles or individual permissions.
- Integration and extension Fluent CMS can be integrated into projects via a NuGet package.
Validation logic can be implemented using C# statements through DynamicExpresso, and complex functionalities can be extended using CRUD Hook Functions. Additionally, Fluent CMS supports message brokers like Kafka for CRUD operations. - Performance: Utilizing SqlKata and Dapper, Fluent CMS achieves performance levels comparable to manually written RESTful APIs using Entity Framework Core. Performance benchmarks include comparisons against Strapi and Entity Framework.
source code Example Blog Project.
- Admin Panel https://fluent-cms-admin.azurewebsites.net/admin
- Email:
[email protected]
- Password:
Admin1!
- Email:
- Public Site : https://fluent-cms-admin.azurewebsites.net/
The following chapter will guid you through add Fluent CMS to your own project by adding a nuget package.
-
Create your own Asp.net Core WebApplication.
-
Add FluentCMS package
dotnet add package FluentCMS
-
Modify Program.cs, add the following line before builder.Build(), the input parameter is the connection string of database.
builder.AddSqliteCms("Data Source=cms.db"); var app = builder.Build();
Currently FluentCMS support
AddSqliteCms
,AddSqlServerCms
,AddPostgresCMS
. -
Add the following line After builder.Build()
await app.UseCmsAsync();
this function bootstrap router, initialize Fluent CMS schema table
When the web server is up and running, you can access Admin Panel by url /admin
, you can access Schema builder by url /schema
.
The example project can be found at Example Project.
The following chapter will guide you through developing a simple online course system, starts with three entity `Teachers`, `Courses`, and `Students`.
This table stores information about the teachers.
Column Name | Data Type | Description |
---|---|---|
Id |
INT |
Primary Key, unique ID for each teacher. |
FirstName |
VARCHAR |
Teacher's first name. |
LastName |
VARCHAR |
Teacher's last name. |
Email |
VARCHAR |
Teacher's email address. |
PhoneNumber |
VARCHAR |
Teacher's contact number. |
This table stores information about the courses.
Column Name | Data Type | Description |
---|---|---|
Id |
INT |
Primary Key, unique ID for each course. |
CourseName |
VARCHAR |
Name of the course. |
Description |
TEXT |
Brief description of the course. |
TeacherId |
INT |
Foreign Key, references TeacherId in the Teachers table. |
This table stores information about the students.
Column Name | Data Type | Description |
---|---|---|
Id |
INT |
Primary Key, unique ID for each student. |
FirstName |
VARCHAR |
Student's first name. |
LastName |
VARCHAR |
Student's last name. |
Email |
VARCHAR |
Student's email address. |
EnrollmentDate |
DATE |
Date when the student enrolled. |
This table manages the many-to-many relationship between Students
and Courses
, since a student can enroll in multiple courses, and a course can have multiple students.
Column Name | Data Type | Description |
---|---|---|
EnrollmentId |
INT |
Primary Key, unique ID for each enrollment. |
StudentId |
INT |
Foreign Key, references StudentId in the Students table. |
CourseId |
INT |
Foreign Key, references CourseId in the Courses table. |
- Teachers to Courses: One-to-Many (A teacher can teach multiple courses, but a course is taught by only one teacher).
- Students to Courses: Many-to-Many (A student can enroll in multiple courses, and each course can have multiple students).
After starting your ASP.NET Core application, you will find a menu item labeled "Schema Builder" on the application's home page.
In the Schema Builder, you can add entities such as "Teacher" and "Student."
When adding the "Course" entity, start by adding basic attributes like "Name" and "Description." You can then define relationships by adding attributes as follows:
-
Teacher Attribute:
Configure it with the following settings:{ "DataType": "Int", "Field": "teacher", "Header": "Teacher", "InList": true, "InDetail": true, "IsDefault": false, "Type": "lookup", "Options": "teacher" }
-
Students Attribute:
Configure it with these settings:{ "DataType": "Na", "Field": "students", "Header": "Students", "InList": false, "InDetail": true, "IsDefault": false, "Type": "crosstable", "Options": "student" }
With these configurations, your minimal viable product is ready to use.
The following chapter will guide you through add your own business logic by add validation logic, hook functions, and produce events to Kafka.
You can add simple c# expression to Validation Rule
of attributes, the expression is supported by Dynamic Expresso.
For example, you can add simple expression like name != null
.
You can also add Validation Error Message
, the end user can see this message if validate fail.
Dynamic Expresso
supports regex, for example you can write Validation Rule Regex.IsMatch(email, "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$")
.
Because Dyamic Expresso
doesn't support Verbatim String, you have to escape \
.
You need to add your own Business logic, for examples, you want to verify if the email and phone number of entity teacher
is valid.
you can register a cook function before insert or update teacher
var registry = app.GetHookRegistry();
registry.EntityPreAdd.Register("teacher", args =>
{
VerifyTeacher(args.RefRecord);
return args;
});
registry.EntityPreUpdate.Register("teacher", args =>
{
VerifyTeacher(args.RefRecord);
return args;
});
You can also choose produce events to Event Broker(e.g.Kafka), so Consumer Application function can implement business logic in a async manner.
The producing event functionality is implemented by adding hook functions behind the scene, to enable this functionality, you need add two line of code,
builder.AddKafkaMessageProducer("localhost:9092");
and app.RegisterMessageProducerHook()
.
builder.AddSqliteCms("Data Source=cmsapp.db").PrintVersion();
builder.AddKafkaMessageProducer("localhost:9092");
var app = builder.Build();
await app.UseCmsAsync(false);
app.RegisterMessageProducerHook();
FluentCMS authorizes access to each entity by using role-based permissions and custom policies that control user actions like create, read, update, and delete.
Fluent CMS' permission control module is decoupled from the Content Management module, allowing you to implement your own permission logic or forgo permission control entirely. The built-in permission control in Fluent CMS offers four privilege types for each entity:
- ReadWrite: Full access to read and write.
- RestrictedReadWrite: Users can only modify records they have created.
- Readonly: View-only access.
- RestrictedReadonly: Users can only view records they have created.
Additionally, Fluent CMS supports custom roles, where a user's privileges are a combination of their individual entity privileges and the privileges assigned to their role.
To enable fluentCMS' build-in permission control feature, add the following line .
//add fluent cms' permission control service
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
builder.AddCmsAuth<IdentityUser, IdentityRole, AppDbContext>();
And add the follow line after app was built if you want to add a default user.
InvalidParamExceptionFactory.CheckResult(await app.EnsureCmsUser("[email protected]", "Admin1!", [Roles.Sa]));
Behind the scene, fluentCMS leverage the hook mechanism.
FluentCMS streamlines frontend development with support for GraphQL-style queries.
As shown in the screenshot below, we aim to design a course detail page. In addition to displaying basic course information, the page should also show related entity data, such as:
In the example below, when displaying a skill, we want to show which teachers have that skill and the courses they teach.
The tables involved include courses
, teachers
, teacher-skill
(the cross referencing table), and skills
.
To create or edit a query, navigate to Schema Builder
> Queries
.
To understand the structure of a FluentCMS query, consider the SQL generated by FluentCMS below, with course
as the primary entity:
teacher
is a lookup attribute of the course.skills
is a cross-table attribute of teacher.
SELECT DISTINCT "courses"."name", "courses"."id", "courses"."desc", "courses"."image", "courses"."summary", "courses"."level", "courses"."status", "courses"."teacher"
from "courses"
left join "teachers" as "teacher" on "courses"."teacher" = "teacher"."id"
left join "skill_teacher" as "skills_skill_teacher" on "teacher"."id" = "teacher_skills_skill_teacher"."teacher_id"
left join "skills" as "teacher_skills" on "teacher_skills_skill_teacher"."skill_id" = "teacher_skills"."id"
WHERE "courses"."deleted" = 0 AND "teacher"."deleted" = 0 AND "teacher_skills_skill_teacher"."deleted" = 0 AND "teacher_skills"."deleted" = 0
AND ("teacher_skills"."id" IN (1))
ORDER BY "courses"."id"
DESC LIMIT 20
The Selection Set specifies which columns to retrieve. You can include columns from the main entity as well as lookup
and crosstable
entities.
{
id,
name,
desc,
image,
level,
status,
teacher{
firstname,
lastname,
image,
bio,
skills{
name,
years
}
},
materials{
name,
image,
link
}
}
Each query is based on a main Entity, setting the main entity's table name as From
clause.
The query's Page Size
property sets to Limit clause
.
Sorts define the order by
clause. Sorting can be applied to the main entity or related entities, e.g., below settings corresponds to:
select * from course left join teachers
on course.teacher = teachers.id
order by teacher.firstname asc, course.id desc
{
"sorts": [
{
"fieldName": "teacher.firstname",
"order": "Asc"
},
{
"fieldName": "id",
"order": "Desc"
}
]
}
Filter settings define the WHERE
clause to control which data is retrieved.
- fieldName: Specifies a column in either the main entity or a related entity.
- operator:
and
requires all constraints to match, whileor
matches any single constraint. - constraints:
- match: Defines how to match values, such as
in
orstartWith
. - value: Can be hardcoded within the query or passed as a query string parameter.
For example,qs.course_id
pulls the ID from the query string parametercourse_id
, where the prefixqs.
indicates that the value comes from the query string.
Example API call:/api/queries/<query-name>/one?course_id=3
SQL equivalent:SELECT * FROM courses WHERE id = 3
- match: Defines how to match values, such as
- omitFail: If the query string does not contain a specific constraint value, this option omits the filter. In the example below, if
skill_id
andmaterial_id
are not provided, FluentCMS ignores these filters.
{
"filters": [
{
"fieldName": "id",
"operator": "and",
"omitFail": true,
"constraints": [
{
"match": "in",
"value": "qs.course_id"
}
]
},
{
"fieldName": "materials.id",
"operator": "and",
"omitFail": true,
"constraints": [
{
"match": "in",
"value": "qs.material_id"
}
]
},
{
"fieldName": "teacher.skills.id",
"operator": "and",
"omitFail": true,
"constraints": [
{
"match": "in",
"value": "qs.skill_id"
}
]
}
]
}
When sorting or filtering by related entities, FluentCMS joins the relevant tables. For example, with course
as the main entity and a filter like teacher.skills.id
, FluentCMS will join these tables:
FROM "courses"
LEFT JOIN "teachers" AS "teacher" ON "courses"."teacher" = "teacher"."id"
LEFT JOIN "skill_teacher" AS "skills_skill_teacher" ON "teacher"."id" = "teacher_skills_skill_teacher"."teacher_id"
LEFT JOIN "skills" AS "teacher_skills" ON "teacher_skills_skill_teacher"."skill_id" = "teacher_skills"."id"
Each query has three corresponding endpoints:
- List:
/api/queries/<query-name>
retrieves a paginated list.- To view the next page:
/api/queries/<query-name>?last=***
- To view the previous page:
/api/queries/<query-name>?first=***
sorts
was applied when retrieving next page or previous page. Example response:
- To view the next page:
{
"items": [],
"first": "",
"hasPrevious": false,
"last": "eyJpZCI6M30",
"hasNext": true
}
-
Single Record:
/api/queries/<query-name>/one
returns the first record.- Example:
/api/queries/<query-name>/one?id=***
- Example:
-
Multiple Records:
/api/queries/<query-name>/many
returns multiple records.- Example:
/api/queries/<query-name>/many?id=1&id=2&id=3
- Example:
If the number of IDs exceeds the page size, only the first set will be returned.
The page designer utilizes the open-source GrapesJS and Handlebars, enabling seamless binding of `GrapesJS Components` with `FluentCMS Queries` for dynamic content rendering.
The GrapesJS Page Designer UI provides a toolbox with four main panels:
- Style Manager: Lets users customize CSS properties of selected elements on the canvas. FluentCMS does not modify this panel.
- Traits Panel: Allows you to modify attributes of selected elements. FluentCMS adds custom traits to bind data to components here.
- Layers Panel: Displays a hierarchical view of page elements similar to the DOM structure. FluentCMS does not customize this panel, but it’s useful for locating FluentCMS blocks.
- Blocks Panel: Contains pre-made blocks or components for drag-and-drop functionality. FluentCMS adds its own customized blocks here.
FluentCMS includes Tailwind CSS by default for page rendering, using the following styles:
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/base.min.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/components.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tailwindcss/[email protected]/dist/typography.min.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/utilities.min.css">
The URL format is /page/<pagename>
.
A landing page is typically composed of multiple Multiple Records Components
, each with its own Query
, making the page-level Query
optional.
The URL format is /page/<pagename>/<router parameter>
, FluentCMS retrieves data by passing the router parameter to the FluentCMS Query
.
For the following settings
- Page Name:
course/{id}
- Query:
courses
FluentCMS will call the queryhttps://fluent-cms-admin.azurewebsites.net/api/queries/courses/one?id=3
for URLhttps://fluent-cms-admin.azurewebsites.net/pages/course/3
The homepage is a special landing page with the name home
. Its URL is /pages/home
. If no other route handles the path /
, FluentCMS will render /
as /pages/home
.
FluentCMS uses Handlebars expression for dynamic data binding.
Singleton fields are enclosed within {{ }}
.
Handlebars
loops over arrays using the each
block.
However, you won’t see the {{#each}}
statement in the GrapesJS Page Designer. FluentCMS adds it automatically for any block under the Multiple Records
category.
Steps to bind multiple records:
- Drag a block from the
Multiple Records
category. - Hover over the GrapesJS components to find a block with the
Multiple-records
tag in the top-left corner, then click theTraits
panel. You can also use the GrapesJS Layers Panel to locate the component. - In the
Traits
panel, you have the following options:- Field: Specify the field name for the Page-Level Query (e.g., for the FluentCMS Query below, you could set the field as
teacher.skills
).{ "teacher": { "firstname": "", "skills": [ { "name": "cooking fish", "years": 3 } ] } }
- 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 There are 3 Options:
Button
, content is divided into multiple pages, and navigation buttons (e.g., "Next," "Previous," or numbered buttons) are provided to allow users to move between the pages.Infinite Scroll
, Content automatically loads as the user scrolls down the page, providing a seamless browsing experience without manual page transitions. It's better to set only one component toinfinite scroll
, and put it to the bottom of the pages.None
. Users see all the available content at once, without the need for additional actions.
- Field: Specify the field name for the Page-Level Query (e.g., for the FluentCMS Query below, you could set the field as
FluentCMS does not customize GrapesJS' Image and Link components, but locating where to input Query Field
can be challenging. The steps below explain how to bind them.
-
Link: Locate the link by hovering over the GrapesJS component or finding it in the
GrapesJS Layers Panel
. Then switch to theTraits Panel
and input the detail page link, e.g.,/pages/course/{{id}}
. FluentCMS will render this as<a href="/pages/course/3">...</a>
. -
Image: Double-click on the image component, input the image path, and select the image. For example, if the image field is
thumbnail_image_url
, input/files/{{thumbnail_image_url}}
. FluentCMS will replace{{thumbnail_image_url}}
with the actual field value.
FluentCMS adds customized blocks to simplify web page design and data binding for FluentCMS Queries
. These blocks use Tailwind CSS.
- Multiple Records: Components in this category contain subcomponents with a
Multiple-Records
trait. - Card: Typically used in detail pages.
- Header: Represents a navigation bar or page header.
Having established our understanding of Fluent CMS essentials like Entity Schemas, GraphQL-style Querying, and GrapeJS-based Page Design, we’re ready to build a frontend for an online course website.
The online course website is designed to help users easily find courses tailored to their interests and goals.
-
Home Page(
home
): This is the main entry point, providingFeatured Course
,Advanced Course
, etc. Each course in these sections links to its Course Details page. -
Latest Courses(
course
): A curated list of the newest courses. Each course in this section links to its Course Details page. -
Course Details(
course/{course_id}
): This page provides a comprehensive view of a selected course. Users can navigate to the Teacher Details page to learn more about the instructor. -
Teacher Details(
teacher/{teacher_id}
): Here, users can explore the profile of the instructor, This page contains ateacher's latest course section
, each course in the sections links back to Course Details
Home Page
|
|
+-------------------+
| |
v v
Latest Courses Course Details
| |
| |
v v
Course Details <-------> Teacher Details
The home page's screenshot shows below.
In the page designer, we drag a component Content-B
, set it's multiple-records
component's data source to Query course
.
The query might return data like
[
{
"name": "Object-Oriented Programming(OOP)",
"id": 20,
"teacher":{
"id": 3,
"firstname": "jane"
}
}
]
We set link href of each course item to /pages/course/{{id}}
.
HandleBar rendering engine renders the link as /pages/course/20
by replacing {{id}}
to 20
.
We name this page course/{course_id}
to capture the path parameter course_id.
For example, with the URL /pages/course/20
, we obtain {course_id: 20}
. This parameter is passed to the Query Service, which then filters data to match:
{
"fieldName": "id",
"operator": "and",
"omitFail": true,
"constraints": [
{
"match": "in",
"value": "qs.course_id"
}
]
}
The query service produces a where clause as where id in (20)
.
We set the link of each teacher item as /pages/teacher/{{teacher.id}}
, allowing navigation from Course Details to Teacher Details:
For below example data, HandlerBar render the link as /pages/teacher/3
.
[
{
"name": "Object-Oriented Programming(OOP)",
"id": 20,
"teacher":{
"id": 3,
"firstname": "jane"
}
}
]
Similarly, we name this page as teacher/{teacher_id}
and set its data source Query to teacher
. For the URL /pages/teacher/3, the query returns:
{
"id": 3,
"firstname": "Jane",
"lastname": "Debuggins",
"image": "/2024-10/b44dcb4c.jpg",
"bio": "<p><strong>Ms. Debuggins</strong> is a seasoned software developer with over a decade of experience in full-stack development and system architecture. </p>",
"skills": [
{
"id": 1,
"name": "C++",
"years": 3,
"teacher_id": 3
}
]
}
To add a list of courses by the teacher, we set a multiple-records
component with Query course
.
When rendering the Teacher Page, PageService sends {teacher_id: 3}
to Query course
.
The QueryService Apply below filter, resulting in WHERE teacher in (3)
.
{
"fieldName": "teacher",
"operator": "and",
"omitFail": true,
"constraints": [
{
"match": "in",
"value": "qs.teacher_id"
}
]
}
This design creates an interconnected online course site, ensuring users can explore course details, instructors.
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.