DEV Community

Cover image for OpenAPI and Frontend
Michael M.
Michael M.

Posted on

OpenAPI and Frontend

Recap

In the previous part of the series, we configured the PostgreSQL database, created the User entity, implemented JSON Web Tokens, and secured our API.

In this part, we will add two more methods in the backend for convenience and demonstration and then build the frontend application.

Logout & User Information

Before jumping to the frontend part, we should take one step ahead and think about how we will test everything without using the browser's Dev Tools. We authenticate users with cookies, and of course, we have to be able to remove them when they sign out, so we introduce the "logout" endpoint:

...

@PostMapping(value = "/logout")
public ResponseEntity<String> logout(
        HttpServletResponse res
) {
    CookiesResult result = this.authService.logout();

    res.addCookie(result.getResult()[0]);
    res.addCookie(result.getResult()[1]);
    return ResponseEntity.status(HttpStatus.OK).build();
}
Enter fullscreen mode Exit fullscreen mode

[AuthController.java]

The AuthService's method just passes the request down to the JwtService, where the logic, yet again, is quite simple. By returning cookies with the same parameters, i.e., name and path, but zeroed expiration time, we're essentially revoking them in the browser the moment it processes the response.

...

public @NonNull Cookie[] revokeCookies() {
    return new Cookie[]{
            this.createAuth("", 0),
            this.createMarker("", 0)
    };
}
Enter fullscreen mode Exit fullscreen mode

[JwtServiceImpl.java]

As I said in the previous article, you should implement a revocation mechanism that blacklists tokens until they expire. But, since this is a demo application, we will only revoke cookies for anyone, even if they're not yet authenticated.

Next, to test that we have signed in successfully and everything is good and well, let's create an endpoint that returns the current user's information.

...

@GetMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UserInfo> getUser() {
    UserInfo user = this.accountService.getUser();
    return ResponseEntity.status(HttpStatus.OK)
            .body(user);
}
Enter fullscreen mode Exit fullscreen mode

[AccountController.java]

And the corresponding service:

@Service
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {

    private final UserRepo userRepo;

    public UserInfo getUser() {
        String principal = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        Optional<User> userOptional = this.userRepo.findFirstById(UUID.fromString(principal));
        if (userOptional.isEmpty()) {
            throw new RuntimeException("user_not_found");
        }

        User user = userOptional.get();
        return new UserInfo(user.getUsername());
    }
}
Enter fullscreen mode Exit fullscreen mode

[AccountServiceImpl.java]

Note how we retrieve the principal (or, in JWT terms, the subject). When the HTTP request hits the application, it's passed through several mechanisms within Spring, as well as our JwtFilter we implemented earlier. It populates the context so we can retrieve this information across the app.

OpenAPI

Since we're done creating endpoints for all operations we need right now, let's add OpenAPI to our application. Doing so allows our clients or other teams to discover the endpoints quickly and potentially generate their code, just like we would do in a moment. In larger projects, this drastically reduces errors and time spent debugging integrations.

We will start by adding a dependency that does pretty much everything for us:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

[pom.xml]

And set it up with just a few lines of configuration properties. We will enable the generation and make it human-readable by enabling "pretty print," but since we won't need the Swagger UI, let's turn it off.

springdoc:
  api-docs:
    enabled: true
  swagger-ui:
    enabled: false
  writer-with-default-pretty-printer: true
Enter fullscreen mode Exit fullscreen mode

[application.yaml]

One last thing would be adding tags to our controllers:

@Tag(name = "auth")
public class AuthController {
    ...
}
Enter fullscreen mode Exit fullscreen mode

[AuthController.java]

We're all set, you may now start the app and open http://localhost:25100/api/v3/api-docs in the browser.

Frontend Models

Creating services and models manually sounds easy enough, but not when you have a huge application with over a hundred endpoints and their contracts change from time to time. Of course, there are tools to automate the models' generation, and for Angular, we will even create services in just a few lines of code.

First, we'll need a dependency npm i -D @public-js/ng-openapi-gen with a few lines of configuration:

{
  ...
  "input": "http://localhost:25100/api/v3/api-docs",
  "output": "src/api/generated"
}
Enter fullscreen mode Exit fullscreen mode

[openapi.json]

And a handy npm script to run it:

...
"scripts": {
  ...
  "api-gen": "ng-openapi-gen --config ./openapi.json"
}
Enter fullscreen mode Exit fullscreen mode

[package.json]

Now, all we need to do is successfully start the backend and, while it's running, execute npm run api-gen to get ourselves all models and services in TypeScript that are ready to use.

With the services in place, we need to plug them into the application. Let's start by creating a proxy configuration to trick the browser into thinking that our backend is served at the exact same domain and port and avoid CORS issues:

{
  "/api/**": {
    "target": "http://127.0.0.1:25100/api",
    "secure": false,
    "changeOrigin": true,
    "pathRewrite": {
      "^/api": ""
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

[proxy-local.json]

We should also make sure that running the npm run start command uses this file every time:

...

"configurations": {
  ...
  "development": {
    ...
    "proxyConfig": "./proxy-local.json"
  }
}
Enter fullscreen mode Exit fullscreen mode

[project.json]

Now that we're able to communicate with the backend during development, we will set up the actual application to work with the generated code. You may either hard-code this path or leverage the power of Angular's file replacement technique, which I highly recommend using.

export const environment = { apiRootUrl: '/api' };
Enter fullscreen mode Exit fullscreen mode

[environment.ts]

Finally, provide the app with the generated API module. To do so, we need to start utilizing Angular's HttpClient, pass our root URL to the Dependency Injection Token, and provide our app with the generated services coupled by a module.

providers: [
    ...
    provideHttpClient(),
    { provide: API_ROOT_URL_TOKEN, useValue: environment.apiRootUrl },
    importProvidersFrom(ApiModule),
]
Enter fullscreen mode Exit fullscreen mode

[app.config.ts]

User Interface

Let's get to the most creative part of this series – the user interface, aka frontend.

We will start by outlining our navigation routes. Note that we don't add any router filters, so we can easily navigate between these pages during development.

export const appRoutes: Route[] = [
    { path: 'account', component: AccountComponent },
    { path: 'login', component: LoginComponent },
    { path: 'signup', component: SignupComponent },
    { path: '', pathMatch: 'full', redirectTo: 'login' },
];
Enter fullscreen mode Exit fullscreen mode

[app.routes.ts]

Moving on to the signup page, start by creating an Angular reactive form. It has the same validation rules for the username field as the backend, so we can catch errors faster. Also, we won't even bother validating the "repeat_password" control, because we will check if it matches the password anyway.

public readonly form = this.fb.nonNullable.group({
    username: this.fb.nonNullable.control<string>('', [Validators.required, Validators.pattern('[\\da-zA-Z_-]+')]),
    password: this.fb.nonNullable.control<string>('', [Validators.required, Validators.minLength(6)]),
    repeat_password: this.fb.nonNullable.control<string>('', [Validators.required]),
});
Enter fullscreen mode Exit fullscreen mode

[signup.component.ts]

Next, we add some HTML to render this form. Not only will our forms be visually pleasing, but they will also be accessible. For the user's convenience, we will explicitly disable the spellcheck and automatic capitalization, even though we look users up in a case-insensitive manner.

Also, since the CSS code is provided in the repo, I will not mention it here at all. You're welcome to either re-use it or write your own from scratch.

<form [formGroup]="form" (ngSubmit)="submit()" class="c-form">
    <div class="w-input">
        <label for="username" class="f-label">Username</label>
        <input
            formControlName="username"
            id="username"
            type="text"
            autocomplete="username"
            autocapitalize="off"
            autocorrect="off"
            spellcheck="false"
            class="f-input"
        />
    </div>

    ...
</form>
Enter fullscreen mode Exit fullscreen mode

[signup.component.ts]

Then, because we need some logic to be done before the data is sent, we will add a method to mark the form "dirty" if the validation fails and add an error message. It will then send the request and handle its response.

...

public submit(): void {
    if (!this.form.valid) {
        this.form.markAsDirty();
        this.errors$.next('The form is invalid');
        this.form.valueChanges.pipe(first()).subscribe(() => this.errors$.next(''));
        return;
    }

    if (this.form.value.password !== this.form.value.repeat_password) {
        this.errors$.next("Passwords don't match");
        this.form.valueChanges.pipe(first()).subscribe(() => this.errors$.next(''));
        return;
    }

    const formValue = this.form.getRawValue();

    this.authService
        .signup({ body: { username: formValue.username, password: formValue.password } })
        .pipe(catchError(this.handleHttpError))
        .subscribe({
            next: () => this.router.navigate(['account']),
            error: (code: string) => this.errors$.next(signupErrors[code] ?? signupErrors['_']),
        });
}
Enter fullscreen mode Exit fullscreen mode

[signup.component.ts]

For a seamless experience, let's add the navigation link that adds the username to the router state.

<div class="c-split">
    <span class="text-split">Already have an account?</span>
    <a routerLink="/login" [state]="{ username: form.value.username }" class="link-split">Sign in</a>
</div>
Enter fullscreen mode Exit fullscreen mode

[signup.component.ts]

The login page looks very alike, so I'll skip ahead and add a method that pulls the username from the router state and applies it to the form:

...

public ngOnInit(): void {
    const navUsername = this.router.lastSuccessfulNavigation?.extras?.state?.['username'];
    if (navUsername) {
        this.form.patchValue({ username: navUsername });
    }
}
Enter fullscreen mode Exit fullscreen mode

[login.component.ts]

How is it convenient? Easy: imagine yourself as a user who tried to sign up on a website and got a message that this user already exists. You will likely remember that some time ago you already signed up here, and when you click the "sign in" link, the login form will greet you with that username already in place. Same applies in the other direction.

So, what about those two endpoind we added at the beginning of this part? We will use them for the account page. Let's (try to) load some data when this page is opened:

...

public ngOnInit(): void {
    this.loadData(this.accountService.getPublic(), this.responsePublic$);

    this.accountService.getUser().subscribe({
        next: (user) => this.userInfo$.next(user),
    });
}
Enter fullscreen mode Exit fullscreen mode

[account.component.ts]

And greet the user by their username, if they're signed in (remember, we don't have router filters):

@if (userInfo$ | async; as userInfo) {
<div class="c-title">
    <h2 class="text-title">Hello, {{ userInfo.username }}</h2>
</div>
}
Enter fullscreen mode Exit fullscreen mode

[account.component.ts]

That's it, you may now spin up everything and see how it works for yourself!

TODO: Add the drumroll sound.

Conclusion

In this part we've implemented a fully-functional accessible frontend based on auto-generated API models from the OpenAPI document.

As a reminder, all the code fragments in this article are in the companion repository, which you can clone or browse online. If you feel any area needs further clarification in the article itself, feel free to point in the comments, and I'll do my best.

We can now proceed to more complex and even more exciting functionality like access control, multi-factor authentication, and WebAuthn.


Cover image by Karen Grigorean on Unsplash

Top comments (0)