For LWT 2.9.0 (released December 2024), I replaced the old AJAX calls in LWT with a proper REST API. Here’s how the process went.
Learning REST Basics
Before starting, I had to understand the core principles: REST uses HTTP methods (GET, POST, PUT, DELETE) to interact with resources identified by URIs, and communication is stateless.
Identifying Resources
Server-side definition of AJAX endpoints: a bunch of files
The hard part with LWT was migrating without breaking anything. Until 2.9.0, every time a server resource was needed, a new AJAX file was created with no consistency. I started by inventorying all endpoints and the data they exposed.
URIs Design and Definition
## GPT 3
Sure! Based on the provided resources and actions, here is a suggestion for a coherent REST API design:
1. GET API Endpoints:
- Get API Version: `GET /api/version`
- Get Next Word to Test: `GET /api/test/next-word`
- Get Tomorrow's Tests Number: `GET /api/test/tomorrow`
- Get Phonetic Reading: `GET /api/text/phonetic-reading`
- Get Theming Path: `GET /api/text/theme-path`
- Get Texts Statistics: `GET /api/text/statistics`
- Get Media Paths: `GET /api/media/paths`
- Get Example Sentences: `GET /api/sentences/{word}`
- Get Imported Terms: `GET /api/terms/imported`
- Get Similar Terms: `GET /api/terms/{term}/similar`
- Get Term Translations: `GET /api/terms/{term}/translations`
2. POST API Endpoints:
- Update Reading Position: `POST /api/reading/position`
- Add/Update Translation: `POST /api/translation/{word}`
- Increment/Decrement Term Status: `POST /api/terms/{term}/status`
- Set Term Status: `POST /api/terms/{term}/status/set`
- Test Regular Expression: `POST /api/regexp/test`
- Set Term Annotation: `POST /api/terms/{term}/annotation`
- Save Setting: `POST /api/settings`
Once the resources were identified, I designed URIs following REST conventions: clear names that describe the resource, nothing irrelevant.
Fixing HTTP Methods
Some data was being fetched via POST, and some was submitted via GET… I straightened this out by mapping each action to the correct HTTP method.
Response Formats
The previous APIs used all sorts of formats. Some oddities:
- Answer as an HTML string to display a page
- JavaScript to be ran through
eval - SQL to be ran by the server
I standardized everything to JSON responses with proper HTTP status codes.
API Versioning
I added versioning early on (via the URL) to keep backward compatibility and allow a gradual migration.
Replacing AJAX Calls
With the backend ready, I progressively replaced the old AJAX calls in the frontend JavaScript with calls to the new REST endpoints.
Testing and Iterating
Since this was a big change, manual testing wasn’t realistic. I added an NPM setup to LWT with Chai and supertest to test the API properly.
As the API matured, repeated patterns emerged and I could simplify. In my initial post, I defined a first set of endpoints, then realized some arguments were repetitive or irrelevant, which led me to a new structure better tailored to my needs.
Final result
Now that we defined the endpoints, standardized the format implemented a solution and tested it, let’s have a look at our new API. The full code is in lwt/api.php, if you want to have a look.
function request_handler($method, $requestUri, $post_param) {
// Extract requested endpoint from URI
$req_endpoint = endpoint_exits($method, $requestUri);
$endpoint_fragments = preg_split("/\//", $req_endpoint);
// Process endpoint request
if ($method === 'GET') {
// Handle GET request for each endpoint
$uri_query = parse_url($requestUri, PHP_URL_QUERY);
if ($uri_query == null) {
$req_param = array();
} else {
parse_str($uri_query, $req_param);
}
switch ($endpoint_fragments[0]) {
case 'media-files':
$answer = media_files($req_param);
send_response(200, $answer);
break;
case 'phonetic-reading':
$answer = get_phonetic_reading($req_param);
send_response(200, $answer);
break;
case 'review':
switch ($endpoint_fragments[1]) {
case 'next-word':
$answer = word_test_ajax($req_param);
send_response(200, $answer);
break;
case 'tomorrow-count':
$answer = tomorrow_test_count($req_param);
send_response(200, $answer);
break;
default:
send_response(
404,
['error' => 'Endpoint Not Found' .
$endpoint_fragments[1]]
);
}
break;
case 'sentences-with-term':
// Process
break;
case 'similar-terms':
// Process
break;
case 'settings':
switch ($endpoint_fragments[1]) {
case 'theme-path':
$answer = get_theme_path($req_param);
send_response(200, $answer);
break;
default:
send_response(
404,
['error' => 'Endpoint Not Found: ' .
$endpoint_fragments[1]]
);
}
break;
case 'terms':
// Process
break;
case 'texts':
// Process
case 'version':
$answer = rest_api_version($req_param);
send_response(200, $answer);
break;
// Add more GET handlers for other endpoints
default:
send_response(
404,
['error' => 'Endpoint Not Found: ' .
$endpoint_fragments[0]]
);
}
} elseif ($method === 'POST') {
// Handle POST request for each endpoint
switch ($endpoint_fragments[0]) {
case 'settings':
$answer = save_setting($post_param);
send_response(200, $answer);
break;
case 'texts':
if (!ctype_digit($endpoint_fragments[1])) {
send_response(
404,
['error' => 'Text ID (Integer) Expected, Got ' .
$endpoint_fragments[1]]
);
}
$post_param["text_id"] = (int) $endpoint_fragments[1];
switch ($endpoint_fragments[2]) {
case 'annotation':
$answer = set_annotation($post_param);
send_response(200, $answer);
break;
case 'audio-position':
$answer = set_audio_position($post_param);
send_response(200, $answer);
break;
case 'reading-position':
$answer = set_text_position($post_param);
send_response(200, $answer);
break;
default:
send_response(
404,
['error' => 'Endpoint Not Found: ' .
$endpoint_fragments[2]]
);
}
break;
case 'terms':
// Process
break;
default:
send_response(
404,
['error' => 'Endpoint Not Found On POST: ' .
$endpoint_fragments[0]]
);
}
}
}
Now, let’s look a client would request a new word (where doing a word remembering test), in JS. You can find the full code at lwt/do_test_test.php#483.
/**
* Get a new word test.
*/
function get_new_word()
{
// Get new word through AJAX
const review_data = $count,
"test_key" => $selector,
"selection" => $selection,
"word_mode" => $nosent,
"lg_id" => $lgid,
"word_regex" => (string)$lang['regexword'],
"type" => $testtype
)); ?>;
$.getJSON(
'inc/ajax.php/v1/tests/next-word?' + $.param(review_data)
).done(test_query_handler);
// Close any previous tooltip
cClick();
}
Now, even complex requests now use a much more simple format. This specific feature was previously sending SQL code through AJAX, now we only send the minimal information.
Conclusion
Moving from ad-hoc AJAX to a REST API was a lot of work, but the result is much cleaner: smaller requests, proper formats, and an architecture that’s actually testable. It also makes future development on LWT a lot easier.