11 KiB
Architecture
This file contains description of the software architecture. Before starting any development, consult this file.
The plugin is made out of a Gutenberg widget that communicates with the backend using endpoints handled by PHP. The endpoints are defined in the controllers, that handle everything between the request and creating a application specific models to pass down to the application. It hides all the details about network communication.
Forms
The main component of the plugin are the forms. The administrator defines information that the user submits to them. There are two objects, the definition and submission. The administrator makes the definition: list of fields & elements the form is made out of. The user fills in the information and submits the form.
Submitting forms
Use POST endpoint forms/{form_id} to submit form. The RsvFormProcessor will validate the form and call handler for each element in the definition.
JavaScript
The JavaScript is mostly used for frontend components and slightly for the administration editor of the Wordpress widget. Each JS file and symbol in global scope must have the prefix Rsv or rsv. Each component should be encapsulated within it's own const object in separate file. The root directory is assets/js/components/.
The other JavaScript files are split between user-space and admin-space. Example of admin-space component is Wordpress-looking datagrid. Example of user-space component is calendar.
The JavaScript code should always use a resource object for communicating with the backend REST API. See Data Layer below.
Error handling
Data Layer
All REST API communication goes through the data layer in assets/js/datasource/.
RsvDataSource
RsvDataSource is a factory object with a single method:
RsvDataSource.create_rsv_resource(base_url, { nonce })
It returns a resource object that wraps fetch and provides a consistent CRUD interface:
| Method | Signature | HTTP |
|---|---|---|
get_page |
(skip = 0, limit = 20, params = {}) |
GET base_url?skip=…&limit=… |
get |
(id) |
GET base_url/{id} |
post |
(data) |
POST base_url |
put |
(id, data) |
PUT base_url/{id} |
delete |
(id) |
DELETE base_url/{id} |
Every method returns a Promise. Non-2xx responses reject with an Error. A 204 No Content response resolves to null.
When a nonce is supplied, it is sent as the X-WP-Nonce header, which satisfies WordPress REST API authentication.
Resource files
Each REST endpoint has its own factory function in assets/js/datasource/. The factory calls RsvDataSource.create_rsv_resource and fills in the endpoint path using the ReservairServiceAPI.restUrl global (injected by PHP via wp_localize_script).
| File | Factory | Endpoint |
|---|---|---|
RsvReservationResource.js |
RsvReservationResource() |
/reservation |
RsvFormDefinitionResource.js |
RsvFormDefinitionResource() |
/form-definition |
RsvTimetableResource.js |
RsvTimetableResource() |
/timetable |
RsvTimetableCapacityResource.js |
RsvTimetableCapacityResource(id) |
/timetable/{id}/capacity |
To add a new endpoint, create a new file following the same pattern and register it in includes/RsvAssetsDefinition.php.
Admin REST Forms
Admin pages that write to the REST API follow a standard pattern to keep the code consistent and easy to extend.
HTML
Use a <form> element with three data attributes:
<form
id="my_form"
action="<?= esc_url(get_rest_url(null, 'reservations/v1/some-resource')) ?>"
data-method="POST"
data-success-msg="Record created.">
<!-- inputs with name= attributes -->
<button type="submit" class="button button-primary">Save</button>
</form>
action— the full REST endpoint URL (useget_rest_url+esc_url).data-method— HTTP verb (POST,PATCH,PUT,DELETE). Standard HTML forms only support GET/POST; this attribute carries the real verb for the JS layer.data-success-msg— human-readable message passed toshow_noticeon success.
Fields that should not appear as literal HTML inputs (e.g. a <select> populated asynchronously) still use name= so FormData picks them up automatically at submit time.
JavaScript
Attach a submit listener that reads everything it needs from the form element itself:
document.getElementById('my_form').addEventListener('submit', function(event) {
event.preventDefault();
const form = event.currentTarget;
const fd = new FormData(form);
fetch(form.action, {
method: form.dataset.method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify({
some_field: fd.get('some_field') || null,
// ...
}),
})
.then(response => {
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'Request failed'); });
show_notice(form, 'success', form.dataset.successMsg);
})
.catch(err => show_notice(form, 'error', err.message));
});
Rules:
- Always call
event.preventDefault()first. - Read the endpoint from
form.actionand the verb fromform.dataset.method— never hard-code them in the handler. - Pass
X-WP-Nonce: ReservairServiceAPI.nonceon every authenticated request. - Use
show_notice(form, 'success'|'error', message)for user feedback — do not roll custom status spans. - Any async pre-population (e.g. fetching a list of calendars to fill a
<select>) runs independently of the submit listener and writes its results into named<select>/ hidden<input>elements soFormDatapicks them up transparently.
Forms
The plugin allows administrators to define & display forms on pages as a widget. The definitions is an ordered list of element definitions in a JSON format. The required attributes for each element are name, label and is_required. The optional are description.
To define new element, create a PHP class in includes/Services/Forms/ like RsvFormHelloElementHandler that derives from RsvFormElementHandler and implement all the abstract methods. The RsvFormHandler validates and commits the forms.
The form values are saved as JSON with keys for the input elements.
PHP
Controllers
Builds the REST API. Should not assume anything about the application and mainly just pass built domain object to the application layer. Each controller manages a single resource and defines operations that the user can do with the object.
Permissions & Capabilities
All administrative functionality — both the REST API and the admin pages — is gated by a single custom capability, and every REST route declares its intended audience through a small policy class. There are two source-of-truth symbols; nothing else should appear in authorization code:
| Symbol | File | Purpose |
|---|---|---|
RsvCapabilities::MANAGE ('manage_reservations') |
includes/RsvCapabilities.php |
The capability that authorises managing reservation data. |
RsvRestPolicy::admin() / RsvRestPolicy::open() |
includes/Controllers/RsvRestPolicy.php |
The permission_callback tiers every route uses. |
Rule: never write
'manage_options'or'__return_true'in this plugin. Routes go throughRsvRestPolicy; menus and page-level checks useRsvCapabilities::MANAGE. This keeps the whole authorization surface greppable and consistent.
The capability
manage_reservations is a custom capability (not WordPress's built-in manage_options) so that reservation management can later be granted to a non-admin role without also handing over the whole site. By default it is granted only to the administrator role.
Lifecycle
WordPress only runs the activation hook on activate, never on a plugin update — so granting the cap solely on activation would silently lock admins out after an update. RsvCapabilities handles all three moments:
| Moment | Call | Effect |
|---|---|---|
| Activation | RsvInstaller::install() → RsvCapabilities::ensure() |
Grants the cap to the default roles. |
| Every request | rsv_bootstrap() → RsvCapabilities::ensure() |
Self-heals after an update. No-op once the stored version matches. |
| Uninstall | uninstall.php → RsvCapabilities::revoke() |
Removes the cap from every role and clears the marker. |
ensure() is idempotent and guarded by the rsv_caps_version option compared against RsvCapabilities::VERSION. Bump VERSION whenever the capability set changes so existing installs re-grant on their next request. Because ensure() runs on every load, admins never need to manually reactivate the plugin.
Authorization tiers
Each route's permission_callback is one of:
| Tier | Callback | Means |
|---|---|---|
| admin | [RsvRestPolicy::class, 'admin'] |
Caller must have manage_reservations. Returns a WP_Error (401 logged-out / 403 under-privileged) otherwise. |
| open | [RsvRestPolicy::class, 'open'] |
Either genuinely public (form submission, availability lookups) or a capability URL whose secret is validated inside the handler. |
A capability URL is a public endpoint authorised by an unguessable secret in the request rather than by session auth — e.g. the maintainer accept/refuse links (/timetable-reservation/accept|refuse/{code}), the Google webhook (secret channel id in headers), and the OAuth callback. Each is marked open() with a comment naming where its secret is checked.
Rule: any
open()route that is not fully public must authorise its caller from the request inside the handler.open()performs no check itself.
Using a capability
In a REST route (register_rest_route):
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'], // or 'open'
]);
In the admin menu (add_menu_page / add_submenu_page) — pass the constant as the capability argument:
add_menu_page('Reservations', 'Reservations', RsvCapabilities::MANAGE, 'reservations-settings', 'rsv_reservations_page');
In a page-level / inline check:
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
Adding a new capability
The current design hardcodes the single MANAGE capability for the administrator role. To introduce a second (e.g. a read-only view_reservations for an editor):
- Declare it as a constant on
RsvCapabilities(public const VIEW = 'view_reservations';). - Grant it in
RsvCapabilities::ensure()and remove it inrevoke(). Once there is more than one capability, replace the single-cap logic with acapability => [roles]map and iterate it in both methods, so the two stay in sync. - Bump
RsvCapabilities::VERSIONsoensure()re-applies the new grant on existing installs at their next request. - Expose a tier for it: add a method to
RsvRestPolicy(e.g.viewer()checkingcurrent_user_can(RsvCapabilities::VIEW)), following theadmin()pattern (returntrueor aWP_Errorwithrest_authorization_required_code()). - Reference it from the relevant routes, menu entries, and page checks — never the literal string.