Debugging Objects Headless APIs

Introduction

In my recent blog, Introduction to Liferay Objects, I used Objects and Liferay OOTB facilities to have a list of Course Registrations and UI interfaces for submitting a new Course Registration, listing them, and for admin users the ability to approve or deny them.

It worked well, but it didn't handle maybe some expected requirements. For example, the approve/deny buttons could change the status of any registration, not just the pending ones. The buttons were not aware if the admin had permission to perform only one of the two actions, things like that.

The next blog I started which, at this point, is yet to be released will be building an application to handle the course registrations. It is still going to use the Object that we created in that blog, it's just going to be using more of the generated headless APIs for doing all of the work.

And at this point, the blog is basically finished in draft mode and I also have the custom application ready to go too.

However, I have been plagued with errors being returned when I use any of the headless APIs that are not GET requests. I keep getting 409 (Conflict) return codes which make no sense because there should be no conflict. Also I'm using the test@liferay.com admin user, so permissions shouldn't be an issue, but I don't really know that for certain at this point.

So I needed to track this down and thought that others facing some sort of unexplained API failures might need to do the same, so I thought I'd create a blog describing the things I've done to identify what my problem is and maybe that would come in handy for them too.

Debugging Setup

First I knew I was going to be in the debugger, but instead of debugging a custom application, I was going to be debugging Liferay.

My blog post and application are running on GA 112, so I downloaded the source for that version (well, actually I've forked the https://github.com/liferay/liferay-portal repo, so really I just checked out the 7.4.3-GA112 branch, but a download would have worked also).

Before actually loading the project into the IDE, I do like to build on the command line. This ensures that all necessary artifacts for the build will be available to the IDE when I do load it in.

Building Liferay requires JDK8, so that is a prerequisite. Then use the command line:

$ export ANT_OPTS="$ANT_OPTS -Xms8g -Xmx8g" && ant setup-sdk setup-yarn && \
    ant all && ij

That last ij piece, that is responsible for building the IntelliJ project files for the portal. I'm a big IntelliJ fan and the ij script does a great job at making all of the various folders and subfolders into proper IntelliJ projects. You can download the ij script from here: https://github.com/holatuwol/liferay-intellij.

With these steps complete, you can now go into IntelliJ and just Open the project. IntelliJ will pull it all in, index all of the code, do all of its typical things. Note that Liferay itself is a big project and will take some time to load. Also if you are using a cloned repo, IntelliJ will process all of that history too, so that can add some time to load.

Finally, in order to debug a running Liferay portal, you need to start it up in debug mode. Follow the steps that I've already outlined here: https://liferay.dev/blogs/-/blogs/blade-outside-of-the-ide#intellij-debugging. The only difference is that you should add the Debug configuration to the liferay-portal project, not a custom Liferay workspace that you might have around.

All that remains is to start up your Liferay GA 112 in debug mode, then have IntelliJ connect via remote debugging to the Tomcat instance.

Debugging Objects

Debugging Liferay is really easy for most code, even regular Headless calls. You just find a class that has your entry point, such as the ResourceImpl class that is tied to your entity. If we were trying to debug Structured Content API calls though Headless Delivery, you'd open the StructuredContentResourceImpl class since that has all of the entry points you might need to set breakpoints on.

But this won't work for your custom objects because, well, they don't have a unique implementation. It certainly looks like they should since your custom objects are available in /o/api just like custom RESTBuilder modules are, but that is really just a façade created for us by the Objects framework.

So to set a reasonable breakpoint for the debugger, we'll need to understand a bit more about how Objects is actually wired.

At startup, all custom object definitions will be processed by the com.liferay.object.rest.internal.deployer.ObjectDeployerImpl class. This class will actually use details from the Object Definition as well as methods from the ObjectEntryResourceImpl and will register the JaxRS application with the appropriate name, path, and declared object action methods. This occurs during startup, so if you want to intercept this execution you'll need to add the JPDA_SUSPEND="y" argument to your debug.sh script to have Tomcat wait for the debugger to attach before starting up.

So all actual methods for custom objects will come from the com.liferay.object.rest.internal.resource.v1_0.ObjectEntryResourceImpl class, so you can actually set breakpoints on the public methods of this class to intercept calls.

There are the generic delete, get, patch, post and put methods here for the various activities you want to intercept. Of particular interest may be the putObjectEntryObjectActionObjectActionName() and putScopeScopeKeyByExternalReferenceCodeObjectActionObjectActionName() methods. These are the two entry points used to invoke the standalone object actions that may be defined for your Object.

Now the reason why you may find this to be a challenging place to debug is that it is the one ResourceImpl class that handles all of the custom objects, not just the one that you might be interested in. When you set a breakpoint on, say, getObjectEntriesPage(), this method will be called for every type of object list that is going to be returned, there's just a different instance that would be tied to each individual Object that you have defined.

If you can control your debugging so you're only checking one object at a time, it does make things a bit easier. But if you have an app making a bunch of different calls, it may prove to be challenging to skip the breakpoints in objects you're not worried about.

A great way to debug the objects is actually to use the /o/api page for your specific object type. You can invoke the methods there and hit the breakpoints without a lot of extra noise.

Speaking of /o/api, it may be interesting to set some breakpoints on com.liferay.object.rest.internal.vulcan.openapi.contributor.ObjectEntryOpenAPIContributor. This class is responsible for contributing things to the OpenAPI schema to reflect what is in your custom Object. If you're wondering why a standalone action might not be showing up or how the properties are being handled, this is a good place to check.

The Situation

Okay, so the reason I'm writing this...

Basically I was doing a GET on one of the Course Registration instances using the URL http://localhost:8080/o/c/courseregistrations/scopes/32815/by-external-reference-code/6f704673-30af-a1f3-5ea2-0e6f95b6f7ed. This would run successfully and return the JSON

{
  "actions": {
    "approveRegistration": {
      "method": "PUT",
      "href": "http://localhost:8080/o/c/courseregistrations/
         by-external-reference-code/6f704673-30af-a1f3-5ea2-0e6f95b6f7ed/
         object-actions/approveRegistration"
    },
    "denyRegistration": {
      "method": "PUT",
      "href": "http://localhost:8080/o/c/courseregistrations/
         by-external-reference-code/6f704673-30af-a1f3-5ea2-0e6f95b6f7ed/
         object-actions/denyRegistration"
    },
    "permissions": {
      "method": "GET",
      "href": "http://localhost:8080/o/c/courseregistrations/34905/permissions"
    },
    "get": {
      "method": "GET",
      "href": "http://localhost:8080/o/c/courseregistrations/34905"
    },
    "replace": {
      "method": "PUT",
      "href": "http://localhost:8080/o/c/courseregistrations/34905"
    },
    "update": {
      "method": "PATCH",
      "href": "http://localhost:8080/o/c/courseregistrations/34905"
    },
    "delete": {
      "method": "DELETE",
      "href": "http://localhost:8080/o/c/courseregistrations/34905"
    }
  },
  "creator": {
    "additionalName": "",
    "contentType": "UserAccount",
    "familyName": "Test",
    "givenName": "Test",
    "id": 20122,
    "name": "Test Test"
  },
  "dateCreated": "2024-03-16T04:32:35Z",
  "dateModified": "2024-04-14T23:44:58Z",
  "externalReferenceCode": "6f704673-30af-a1f3-5ea2-0e6f95b6f7ed",
  "id": 34905,
  "keywords": [],
  "scopeKey": "Masterclass",
  "status": {
    "code": 0,
    "label": "approved",
    "label_i18n": "Approved"
  },
  "taxonomyCategoryBriefs": [],
  "registrationStatus": {
    "key": "denied",
    "name": "Denied"
  },
  "notes": "Nothing to say here.",
  "r_courseAttendee_userERC": "aa3313fd-42a0-267b-8366-8c38ef011432",
  "r_courseAttendee_userId": 20122,
  "course": {
    "key": "projectManagerCertificateJuly2024",
    "name": "Project Manager Certificate - July, 2024"
  },
  "courseAttendeeERC": "aa3313fd-42a0-267b-8366-8c38ef011432"
}

I would then display the Approve and Deny buttons and, depending upon which one you clicked, I'd issue a PUT call to the relevant action URL.

Now when I did this, I would always get an error back, a 409 Conflict as the result. But this made no sense to me because nothing that I was doing should have triggered any kind of conflict.

Hence the need to debug the situation. I needed to find out why the PUT actions were failing.

Debugging The Call

So actually I set up breakpoints on all of the classes I discussed earlier.

In ObjectDeployerImpl I set a breakpoint on deploy() so I could see what was being registered from my Object Definition.

In ObjectEntryOpenAPIContributor, I set a breakpoint on contribute() so I could see what was being added to the schema and would appear on the /o/api page for my application.

And, in ObjectEntryResourceImpl I set a breakpoint on putScopeScopeKeyByExternalReferenceCodeObjectActionObjectActionName() because this was the method that should be invoked for either the approve or deny action URLs.

On startup, the first breakpoint I hit was the deploy() method. I could see that my JaxRS application was being properly registered and everything seemed to be wired correctly, so no problem there.

I then tested my React code. In my api() method (which you'll get to see when I publish the blog), I added a console.log() statement to show what I was invoking, and I could see that it was using the URL http://localhost:8080/o/c/courseregistrations/by-external-reference-code/6f704673-30af-a1f3-5ea2-0e6f95b6f7ed/object-actions/approveRegistration, but it was still getting the 409 failure response.

At this point I'm thinking maybe there is something wrong in how the approveRegistration is implemented, so I open a tab to /o/api to try it there manually. As soon as I did this, I hit the next breakpoint in the contribute() method, so I could verify that yes, in fact my two standalone actions were being added correctly to the OpenAPI schema.

At this point I had verified the JaxRS application was registered correctly, and also the right things were being added to the OpenAPI schema, so from a registration perspective everything was looking fine.

So the next step was to find one of the actions and try to run it on /o/api, and that's what I did:

I plugged in my key values and clicked Execute, and wouldn't you know it, it worked just fine and had a return code of 204!

Oh, and the debugger stopped in the putScopeScopeKeyByExternalReferenceCodeObjectActionObjectActionName() call so clearly it was getting to the right method to handle the action URL...

When I tried invoking the action from React, I only got the 409 and never hit the breakpoint, so something before the resource impl was failing and returning the 409. I used the stack trace in the debugger to set breakpoints at every method in the stack hoping that I'd be able to find one that triggered the failure, but that was kind of fruitless. First, the stack was in the bowels of CXF (the implementation Liferay uses for the JaxRS applications), and second I couldn't trace exactly where in CXF it was kicking out and returning the 409.

Now I'm starting to go a little crazy...

I'm wondering why the /o/api call would work but it would fail for my React app.

I eliminated perms as an issue because I'm logged in as the Test admin account and besides, the 409 didn't have anything to do with a permissions issue. Plus I checked the headers that I was sending with the React call and they matched exactly the headers used in the curl example, so the call should have worked.

The Clue

So it was the checking of the headers between my React call and the curl example from /o/api that gave me the clue to the mystery...

My header values matched exactly, but I noticed that the URLs didn't match up...

The curl URL example is http://localhost:8080/o/c/courseregistrations/scopes/32815/by-external-reference-code/6f704673-30af-a1f3-5ea2-0e6f95b6f7ed/object-actions/approveRegistration, but the URL in the approveRegistration action href is http://localhost:8080/o/c/courseregistrations/by-external-reference-code/6f704673-30af-a1f3-5ea2-0e6f95b6f7ed/object-actions/approveRegistration. It's missing a scopes portion in the path!

The Bug

So yes, I had actually been beating my head against the wall on this for days. Not straight through, I had code to finish and, even though I knew the failure was still there, I could think about it while wrapping the other code up.

But I had found a bug. The action URLs are supposed to be HATEOAS so I expected them to just work, but for site-scoped objects, the action URLs are wrong because they don't include the necessary scope portion of the path.

I opened a bug on this issue: https://liferay.atlassian.net/browse/LPD-23177

I also changed my code so it wouldn't use the action URLs, instead I'd manually construct the proper, complete URL to invoke the action. This was all it took to have my React code successfully invoke the standalone actions.

Conclusion

Did I need to debug the Objects headless APIs to find the problem here that turned out to be a product bug?

Maybe not, I mean if my first thought had been to check what URL was being used and whether it matched what was used in /o/api, maybe I could have identified the bug without all of the debugging.

Since that wasn't my first thought, going through the steps to debug the process was really my only option. And, had the failure been something different, debugging may have been the only way to find the issue and either resolve it or report it.

Anyways, I thought this was a useful exercise and that sharing it with you might provide some insight if you ever find yourself needing to debug the bowels of the Objects headless APIs.

The moral of this story, if there is one - always check your URL to ensure that they are really valid!