Beliebte Suchanfragen
|
//

Spring and Vue - A setup for small projects (Part 2)

17.1.2025 | 10 minutes of reading time

In the first part we presented a setup for a combination of Spring Boot and Vue.js. Now we have to look at how to connect two type-safe languages, TypeScript for the frontend and Java for the backend, through a REST-API and in a type-safe manner. We will do that by looking at an example; creating two pages, for a list of users and for showing details on one user. As in the first part, we focus on convenience for developers.

Creating the OpenAPI Specification

If the same programming language is used on both sides of an API, common transport objects are sufficient for the data definition. Depending on the language, you can then write function definitions or interfaces for operations. The only thing still missing is a library for serialization/deserialization in the desired data format for the transport, for example, JSON.
With different programming languages, you need the objects and operations in both languages. If they are written separately, there is a risk of inconsistencies, apart from the duplication of work. OpenAPI (formerly Swagger) is a standard that can be used to formally specify REST interfaces in a language-agnostic way.
Client and server stubs can be generated via the OpenAPI generator. For the server side, we used the Spring plugin, which can be easily called via the Maven plugin. The generated code uses the delegate pattern: For all operations, it generates interfaces that you implement in a class annotated with @Controller. Generated code is thus separated from the manually written implementation. An incompatible change to the API causes compile time errors, which can't be overlooked.
The API is located in src/main/api/api.yaml, and the generated Java source code for the server is located in target/generated-sources/openapi/src/main/java. We first ran into a pitfall with the path for api.yaml: When calling Maven from the command line it was sufficient to specify it as src/, within the IDE it had to be ${project.basedir}/src, otherwise we just got the unspecific error message that the generation had failed.

1<plugin>
2    <groupId>org.openapitools</groupId>
3    <artifactId>openapi-generator-maven-plugin</artifactId>
4    <version>7.10.0</version>
5    <executions>
6        <execution>
7            <id>Java</id>
8            <goals>
9                <goal>generate</goal>
10            </goals>
11            <configuration>
12                <generatorName>spring</generatorName>
13                <inputSpec>${project.basedir}/src/main/api/api.yaml</inputSpec>
14                <configOptions>
15                    <delegatePattern>true</delegatePattern>
16                    <useJakartaEe>true</useJakartaEe>
17                    <apiPackage>de.codecentric.generated.api</apiPackage>
18                    <modelPackage>de.codecentric.generated.model</modelPackage>
19                    <configPackage>
20                        de.codecentric.generated.configuration
21                    </configPackage>
22                    <basePackage>de.codecentric.generated</basePackage>
23                </configOptions>
24            </configuration>
25        </execution>
26    </executions>
27</plugin>

In addition to the server implementation in Java, a client implementation in TypeScript is also required. We also found a generator for TypeScript in the list of OpenAPI generators, but this did not meet our requirements. Instead, we used OpenAPI-TS. Although it does not exist as a plugin, it can be easily called via the command line or the exec-maven-plugin. The target for the generated file is src/vueapp/src/generated/api.ts.

1npx openapi-typescript src/main/api/api.yaml --output src/vueapp/src/generated/api.ts

The code uses openapi-fetch, so the client - like the server - is also type-safe.
The API adheres to the REST principles. Only GET is specified for the path /users, the result is a list of user objects. In a larger project, this could be extended a little: paging, smaller objects for the list display, query parameters for restricting the result set, etc.
An individual user is addressed via /users/{id}. The GET and PUT operations are specified there. We have omitted DELETE. To prevent the api.yaml file from growing indefinitely, we have moved the definition of all objects to separate files and only reference them. A user object with the three required properties id, firstName and lastName is therefore very clear.

Implementation of the sever

As mentioned above, the controller must implement the delegate interface for objects of type user. A method exists in the interface for each of the three HTTP verbs specified in the OpenAPI (GET to users, GET/PUT to users/{id}). The path variable {id} becomes a method parameter. In a “real” project, the users would probably be stored in a database; for the demo, we have limited ourselves to a HashMap, which is filled with a few sample data in the constructor.

1@Controller
2public class UserControllerImplementation implements UsersApiDelegate {
3
4    private final HashMap<String, User> usersById = new HashMap<>(Stream.of(
5            new User("1", "Max", "Musterman"),
6            new User("2", "Lisa", "Müller"),
7            new User("3", "John", "Doe"),
8            new User("4", "Jane", "Smith"),
9            new User("5", "Tom", "Brown"),
10            new User("6", "Emma", "Johnson"),
11            new User("7", "Oliver", "Williams"),
12            new User("8", "Sophia", "Davis"),
13            new User("9", "Liam", "Jones"),
14            new User("10", "Ava", "Garcia"),
15            new User("11", "Noah", "Martinez"),
16            new User("12", "Isabella", "Hernandez")
17    ).collect(Collectors.toMap(User::getId, Function.identity())));
18
19    @Override
20    public synchronized ResponseEntity<List<User>> usersGet() {
21        List<User> users = new ArrayList<User>(usersById.values());
22        return ResponseEntity.ok(users);
23    }
24
25    @Override
26    public synchronized ResponseEntity<User> usersIdGet(String id) {
27        User user = usersById.get(id);
28        if (user == null) {
29            throw new NotFoundException("User not found");
30        } else {
31            return ResponseEntity.ok(user);
32        }
33    }
34
35    @Override
36    public synchronized ResponseEntity<User> usersIdPut(String id, User user) {
37        usersById.put(id, user);
38        return ResponseEntity.ok(user);
39    }
40
41}

Returning the 404 response for a user not found caused some problems: In OpenAPI, return objects can be specified per HTTP response code, here User for a found (200) and Error for a not found (404). In the generated Java code, however, the return type is ResponseEntity<User>. It would be better to use ResponseEntity<User|Error>, but this is not supported by Java. A solution is to use ResponseEntity.notFound(). This results in a 404 response code, but with an empty body. We have chosen another way: A custom exception annotated with @ResponseStatus(HttpStatus.NOT_FOUND). This returns both the correct response code and a body. In the IDE with stacktrace, in the production version without stacktrace. If you want, you can customize this further, see Spring Exceptions.

Implementation in the client

To automate the handling of multiple routes, we are not using the vue-router but the unplugin-vue-router. This is a wrapper around the normal vue-router with additional features, such as the automatic registration of routes, typed routes and data loaders. Details can be found in the documentation of the plugin and in the corresponding GitHub repository. Here is the short version: First, we load the plugin in vite.config.ts. Then we move the old views (HomeView and AboutView) to the pages directory and rename them accordingly to index.ts and about.ts. The next time npm run dev is called, a typed-router.d.ts file is created. In our old router definition, we can now replace the manual route definition with the automatically generated routes.

1const router = createRouter({
2    history: createWebHistory(import.meta.env.BASE_URL),
3    routes
4})
5
6if (import.meta.hot) {
7    handleHotUpdate(router)
8}
9
10export default router

To make better use of openapi-fetch, we create a new composable in which we instantiate a fetch client. We can simply specify the URL of our backend, in our example a relative path is also sufficient: /api.

1export default () => {
2    // declare fetcher for paths
3    return createClient<paths>({
4        baseUrl: '/api',
5    })
6}

With the fetch client we have just created, we can now create another composable that groups together all operations related to users. This means that the fetch client only has to be called directly in one place and a simpler method can be called in all other places, which would also make possible refactoring easier.
In the fetchUser method, we call the url /api/users/{id}, but since we have specified a BasePath in the client, we only need to specify the last part /users/{id} here. The ID is then passed as the second parameter.

1const fetchUser = async (id: string) => {
2    return client.GET('/users/{id}', {
3        params: {
4            path: {id}
5        }
6    })
7}

The putUser method is structured similarly, but in addition to the ID it also receives a user object, which is then also sent in the request.

1const putUser = async (id: string, user: UserRequest) => {
2    return client.PUT('/users/{id}', {
3        body: user,
4        params: {
5            path: {
6                id
7            }
8        }
9    })
10}

All these methods are then grouped in one object which is then returned from the composable method.

1export const useUsers = () => {
2    const client = useApiClient()
3
4    const fetchUser = async (id: string) => {...}
5
6    const putUser = async (id: string, user: UserRequest) => {...}
7
8    return { fetchUser, putUser }
9}

Vue Components and Data Loaders

Next we create a new composable for the user details page under pages/users/[id].vue. Here we define two script tags, one normal one and one setup one. In the normal one we create and export a dataloader using defineBasicLoader and pass the path of the page as the first argument. As the second argument we pass a method in which we call the fetchUser method from the composable we created. We then check if the request failed, if it did, and we got a 404 status code, we return a NavigationResult. If the router plugin receives such a result from a dataloader, it cancels the navigation and instead navigates to the page specified in it. In this case, we want to load the user overview page when we try to access a non-existing user.
But if we now try to access an url for a non-existent user, we would see that this redirect doesn’t work. Unfortunately, the openapi-fetch library expects that a response contains a body, even if it is a 4xx status code. Only then it will create an error. This is the reason for all the fuss in Spring described above: It has to be a JSON response, not just an empty response.

1export const useUserData = defineBasicLoader('/users/[id]', async (to) => {
2    return fetchUser(to.params.id).then(res => {
3        console.log(res)
4        if (res.error) {
5            console.log(res)
6            if (res.response.status === 404) {
7                return new NavigationResult("/users")
8            }
9            throw new Error(res.error.message)
10        } else {
11            return res.data!
12        }
13    })
14})

This dataloader is then automatically registered by the unplugin-vue-router and called once the page is loader or once the url has changed. In order for this automatic registration to work, we have to export the dataloader.
In the setup part of the component, we can then call the returned dataloader function which returns a ref, either containing the requested data or undefined as long as no data was received yet. The data can then be used in the template part of the component.
To update the user data in the backend, we create a submit method in which we call the putUser method from the user composable. Here we pass the data object that we got from the dataloader and that we can change using a form with some inputs.

1const submit = () => {
2  putUser(data.value.id, data.value).then((res) => {
3    reload()
4  })
5}

Possible architecture for bigger projects

The project setup we showed in this article works very well for small projects. Many nice-to-have features like hot-code-reload for both Spring and Vue, and in the end, we get a single jar file containing the backend and frontend that we can execute directly.

In the demo, we have left out many things that are necessary in a real project:

  • HTTPS / SSL configuration
  • Security (at least for the /api path)
  • Database instead of HashMap for persistence

There is a lot of documentation and examples available on the Internet for these topics, so we have left them out here.

The approach of combining everything in a common project (and therefore file tree) no longer scales when the complexity (number of pages) increases. It then makes sense to split the code across several repositories. One repository each for the backend and frontend and one for the API definition. That API definition is then used in a CI/CD pipeline to automatically build a java package containing the Spring base and a npm package that contains the types for the fetch client. They can then be included in the other two projects, and you can keep them updated using some automation like the renovate bot. It is then also possible to automatically build the backend and frontend and e.g., deploy them to a kubernetes cluster, but you could also deploy them manually behind a reverse proxy. In both cases, you then would have to configure CORS correctly if the backend is running on a different domain (or just a different subdomain).

The full project can be found in our GitHub repository.

|

share post

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.