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

All AJAX files in LWT 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.