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.

New Comment