Jakarta REST Tip: Custom Path Param Resolvers
Mon Jul 21 19:07:09 EDT 2025
When you're designing a REST API or MVC app of more than basic complexity, you're likely to end up with a lot of different endpoints with the same path components. For example, in the new OpenNTF home app, projects have a lot of sub-pages with URL paths like:
- projects/{projectName}
- projects/{projectName}/releases
- projects/{projectName}/releases/{releaseId}
- projects/{projectName}/screenshots
- projects/{projectName}/screenshots/{screenshotId}
This will likely start with a class like this, using Jakarta Data to get your model objects:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | package controller.projects; import jakarta.inject.Inject; import jakarta.mvc.Controller; import jakarta.mvc.Models; import jakarta.mvc.View; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import model.projects.Project; @Path("projects") @Controller public class ProjectsController { @Inject private Project.Repository projectsRepository; @Inject private Models models; @Path("{projectName}") @GET @View("project/summary.jsp") public void showProject(@PathParam("projectName") String projectName) { String key = projectName.replace('+', ' '); Project project = projectsRepository.findByProjectName(key) .orElseThrow(() -> new NotFoundException("Could not find project for key " + key)); models.put("project", project); } @Path("{projectName}/releases") @GET @View("project/releases.jsp") public void showProjectReleases(@PathParam("projectName") String projectName) { String key = projectName.replace('+', ' '); Project project = projectsRepository.findByProjectName(key) .orElseThrow(() -> new NotFoundException("Could not find project for key " + key)); models.put("project", project); } // And so forth } |
That's fine and all, and you can scale it out as you add more "sub"-resources by having another controller with e.g. @Path("projects/{projectName}/releases")
. But even in the basic case, there's a clear problem: I've repeated the same logic of getting the project based on the name parameter, and that will be repeated in almost every method in this and related resources.
This can be alleviated a bit by moving @PathParam("projectName") String projectName
to the class level and having a utility method to do the lookup, but that's not quite ideal. What we really want is a way to encapsulate the meaning of this translation in a way that can be used transparently any time we need it.
Path Param Converters
By default, path params are naturally strings, but the REST framework knows how to convert them to basic values - "1"
to integer 1
, "true"
to boolean true
, and the like. This is an extensible mechanism, though, and so we can teach our app to convert project names to Project
model objects.
To do this, we'll make a new class that implements jakarta.ws.rs.ext.ParamConverter
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | package rest.ext; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.ext.ParamConverter; import model.projects.Project; @ApplicationScoped public class ProjectParamConverter implements ParamConverter<Project> { @Inject private Project.Repository projectRepository; @Override public Project fromString(String value) { String key = value.replace('+', ' '); return projectRepository.findByProjectName(key) .orElseThrow(() -> new NotFoundException("Unable to find project for name: " + key)); } @Override public String toString(Project value) { return value.getName(); } } |
We make this a CDI bean so that it can participate in the whole CDI environment and get our data repository. Here's where we put our logic, allowing us to centralize what it means to go from a string to a model object. We also provide a way to convert back, for whatever the framework uses that for.
Just creating this as a bean isn't quite enough - we still need to tell the REST runtime about it. To do that, we create a class that implements jakarta.ws.rs.ext.ParamConverterProvider
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | package rest.ext; import java.lang.annotation.Annotation; import java.lang.reflect.Type; import jakarta.inject.Inject; import jakarta.ws.rs.ext.ParamConverter; import jakarta.ws.rs.ext.ParamConverterProvider; import jakarta.ws.rs.ext.Provider; import model.projects.Project; @Provider public class ProjectParamConverterProvider implements ParamConverterProvider { @Inject private ProjectParamConverter projectParamConverter; @SuppressWarnings("unchecked") @Override public <T> ParamConverter<T> getConverter(Class<T> rawType, Type genericType, Annotation[] annotations) { if(Project.class.equals(rawType)) { return (ParamConverter<T>)projectParamConverter; } return null; } } |
With the @Provider
annotation, REST will pick up on this and ask it for a converter whenever it encounters a type it doesn't inherently understand. For now, we cover just Project
, but we can expand this as more types come into play (for example, project releases, which will also be in path parameters).
Putting It To Use
With this in place, we can go back to our ProjectsController
class and clean it up:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | package controller.projects; import jakarta.inject.Inject; import jakarta.mvc.Controller; import jakarta.mvc.Models; import jakarta.mvc.View; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import model.projects.Project; @Path("projects") @Controller public class ProjectsController { @Inject private Models models; @PathParam("project") private Project project; @Path("{project}") @GET @View("project/summary.jsp") public void showProject() { models.put("project", project); } @Path("{project}/releases") @GET @View("project/releases.jsp") public void showProjectReleases() { models.put("project", project); } // And so forth } |
That's noticeably cleaner! It also means that our controller layer now has less to know about: if we change how projects are looked up (say, allowing UNIDs as well as names), this class doesn't need to change. The code is also much more explicit - we don't really care about the project name as such, but rather just that we have a URL that includes whatever the identifier for a project is. Someone entirely new to Jakarta REST may not know how that translation happens, but, once you know how it works, it's much clearer.
Figuring this out reinforced an important lesson for me: whenever I find that I'm building something out in a way that feels gangly, there's usually an idiomatic solution to the problem that improves code readability and maintainability. It's one of the advantages to using a mature and broad-scoped framework like this.