Build an Airline Offers API

Build a Spring Boot API for personalized airline ancillary offers in MongoDB.

Introduction

30 Second Summary

Flight extras can feel random when every traveler sees the same choices. A useful offer reflects the journey plus the person taking it.

In this project, you will build a context-aware airline retail REST API that returns relevant baggage, seat, and lounge offers for each traveler. Spring Boot will serve each request while MongoDB keeps the catalog available between runs.

What You'll Build

You will compare two searches for the same route to see each traveler receive a different ranked offer list.

By the end of this project, you'll have:

  • Browse a persisted catalog to see baggage, seat, and lounge products returned as JSON.
  • Compare contextual searches for a basic traveler and a Gold member to see prices plus eligibility change.
  • Demonstrate operational safeguards through rejected incomplete searches, protected administrative writes, MongoDB health details, and a green GitHub Actions run.
  • Secret Mission: Assign travelers consistently to A/B offer variants and prove that each assignment stays stable between requests.

Are there any prerequisites?

Experience building CRUD APIs with Spring is expected. You also need macOS with JDK 21, IntelliJ IDEA, Git, Docker Desktop, plus a free GitHub account.

Before We Start

Before any hands-on work, lock in the airline retail goal: an ancillary offer API that ranks baggage, seat, and lounge options using route, cabin, and loyalty context. This commitment keeps every later choice focused on showing each traveler relevant offers.

Set Up Spring Boot and MongoDB

Your airline offer rules need a consistent runtime before the first endpoint can rely on its database. This step removes machine-specific drift from that foundation.

Spring Boot provides the Java application baseline. Docker isolates MongoDB while preserving its catalog data between container restarts.

In this step, get ready to:
  • Confirm that your installed JDK supports Java 21.
  • Generate a Spring Boot 4.1.1 Maven project.
  • Verify the generated build plus the MongoDB container.
Verify Java 21 and generate the project

The project targets Java 21. Checking the active JDK now prevents confusing compiler failures during the first Maven build.

  • Press Cmd+Space to open macOS search.
  • Type Terminal.
  • Press Enter to open Terminal.
  • Check the active Java version by running this command:
java -version

What Does This Command Check?

The command reports the JDK used by terminal programs such as the Maven Wrapper. Spring Boot 4.1.1 supports Java 21 through Java 26.

✔️ I see version 21 through 26

Your JDK is compatible with the project target. Keep this Terminal window open for the project generation command.

ⓧ I see an older version

macOS can keep multiple JDK releases installed. You can select JDK 21 without removing the older release.

  • Install JDK 21 by following the official macOS installation guide.
  • Return to Terminal after the installation finishes.
  • Select JDK 21 for the current Terminal session by running this command:
export JAVA_HOME=`/usr/libexec/java_home -v 21`

How Does This Select Java 21?

The macOS java_home tool locates the installed JDK 21 directory. Setting JAVA_HOME makes Maven use that installation in this Terminal session.

  • Run the version check at the top of this substep again.
  • Confirm that the output now includes version 21.

ⓧ Command not found

The shell cannot find a Java runtime. Installing JDK 21 supplies the compiler plus the runtime needed by Maven.

  • Install JDK 21 by following the official macOS installation guide.
  • Close Terminal after the installer finishes.
  • Open Terminal again through macOS search.
  • Run the version check at the top of this substep again.
  • Confirm that the output includes version 21.

Still Seeing the Wrong Java Version?

Close any Terminal windows that were open during the installation. A fresh shell reloads the active Java environment.

If the old release remains active, select JDK 21 with the command in the older-version tab.

Help me select JDK 21 on macOS.

Spring Initializr creates the project descriptor plus the Maven Wrapper. It also generates the application entry point under the package selected in the request.

  • Move to your Desktop by running this command:
cd ~/Desktop

Why Start on the Desktop?

The download command saves the project archive in the current folder. Starting on your Desktop makes the archive easy to locate in Finder.

  • Generate airline-offers-api.zip by running this command:
curl https://start.spring.io/starter.zip \
  -d type=maven-project \
  -d language=java \
  -d bootVersion=4.1.1 \
  -d javaVersion=21 \
  -d groupId=com.example \
  -d artifactId=airline-offers-api \
  -d name=airline-offers-api \
  -d description="Context-aware airline ancillary offers API" \
  -d packageName=com.example.offers \
  -d packaging=jar \
  -d dependencies=webmvc,data-mongodb,validation,actuator \
  -o airline-offers-api.zip

What Does This Command Generate?

  • The request selects a Maven project using Spring Boot 4.1.1 plus Java 21.
  • The dependency IDs add Spring Web MVC, Spring Data MongoDB, Validation, plus Actuator support.
  • The package name places AirlineOffersApiApplication under com.example.offers.
  • The output option saves the generated archive as airline-offers-api.zip.
  • Open the Desktop folder in Finder.
  • Double-click airline-offers-api.zip to extract it.
  • Confirm that Finder shows an airline-offers-api folder.
  • Press Cmd+Space to open macOS search.
  • Type IntelliJ IDEA.
  • Press Enter to open IntelliJ IDEA.
  • Select the extracted airline-offers-api folder in the project chooser.
  • Confirm that the project sidebar contains pom.xml.

The first Maven import can take a couple of minutes while IntelliJ resolves the dependencies. The project is ready when its folders appear without an active import indicator.

Could Not Generate the Project?

Confirm that Terminal has internet access. A failed download can leave an empty or incomplete ZIP file.

Delete the incomplete airline-offers-api.zip file before running the generation command again.

Help me diagnose the Spring Initializr download.

Configure MongoDB storage and connectivity

Docker Compose describes how MongoDB runs beside the application. A named volume keeps database files inside Docker-managed storage that works reliably on macOS.

  • Create compose.yaml beside pom.xml using IntelliJ's file creation control.
  • Paste this configuration into compose.yaml.
services:
  mongo:
    image: mongo:9.0.2
    ports:
      - "127.0.0.1:27017:27017"
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:

What Does This Configuration Do?

  • The mongo service uses the multi-architecture MongoDB 9.0.2 image.
  • The port mapping exposes MongoDB through 127.0.0.1:27017 only.
  • The mongo-data volume preserves database files when the container stops.
  • The container stores those files at /data/db.
  • Save compose.yaml.
  • Confirm that compose.yaml appears beside pom.xml in the project sidebar.

Does IntelliJ Flag the Compose File?

Check that each nested YAML level uses two spaces. Tabs can break the structure even when the values look aligned.

Confirm that volumes appears once inside the service plus once at the top level.

Help me check my Compose YAML.

The application needs a connection URI that names its MongoDB database. Actuator exposure settings prepare the project for the health checks built later.

  • Select src/main/resources/application.properties in the IntelliJ project sidebar.
  • Replace its contents with this configuration:
spring.application.name=airline-offers-api
spring.mongodb.uri=mongodb://localhost:27017/airline_offers
management.endpoints.web.exposure.include=health,metrics
management.endpoint.health.show-details=always

What Does This Configuration Do?

  • The application name identifies the service as airline-offers-api.
  • The MongoDB URI points Spring Boot to the airline_offers database on the locally published port.
  • The management settings expose only health plus metrics over HTTP.
  • The health detail setting makes the MongoDB contribution visible during later checks.
  • Save src/main/resources/application.properties.
  • Confirm that the file contains four property lines.

Is the MongoDB Property Unrecognized?

Confirm that the connection key begins with spring.mongodb.uri. Spring Boot 4.1.1 uses this key for the connection URI.

Check that the URI ends with the airline_offers database name.

Help me check my Spring Boot MongoDB properties.

✔️ Awesome, I've got everything!

Your generated project plus the two configuration files now match the required baseline. Save every open file before running the checks.

ⓧ I'd like to double check the full code

Compare your generated pom.xml with this project descriptor.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>airline-offers-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>airline-offers-api</name>
    <description>Context-aware airline ancillary offers API</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

What Should the Descriptor Contain?

The descriptor targets Spring Boot 4.1.1 plus Java 21. Its starters provide web, MongoDB, validation, monitoring, plus test support.

Compare compose.yaml with this complete file.

services:
  mongo:
    image: mongo:9.0.2
    ports:
      - "127.0.0.1:27017:27017"
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:

What Should the Compose File Contain?

The file defines one MongoDB service with localhost-only publishing. The named volume keeps its database files outside the container lifecycle.

Compare the generated application entry point with this complete file.

package com.example.offers;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AirlineOffersApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(AirlineOffersApiApplication.class, args);
    }
}

What Should the Entry Point Do?

The SpringBootApplication annotation enables the application configuration. The main() method starts the service through Spring Boot.

Compare src/main/resources/application.properties with this complete file.

spring.application.name=airline-offers-api
spring.mongodb.uri=mongodb://localhost:27017/airline_offers
management.endpoints.web.exposure.include=health,metrics
management.endpoint.health.show-details=always

What Should the Properties File Contain?

The properties connect the application to the local airline_offers database. They also prepare health plus metrics endpoints for later monitoring.

Run the build and start MongoDB

The generated tests prove that the Spring application context can load. The database logs provide a separate signal that the pinned MongoDB service is ready for requests.

Before you run the build, make your prediction: will the generated project pass its tests on the first run? Keep your answer in mind.

  • Open IntelliJ's terminal panel from the airline-offers-api project.
  • Run the generated Maven tests with this command:
./mvnw test

What Does the Maven Wrapper Do?

The wrapper uses the Maven version supplied with the generated project. The test goal compiles the project before running its test suite.

You will see BUILD SUCCESS near the end of the output. That result confirms the generated Spring baseline loads correctly.

Does the Maven Build Fail?

Check the first reported error before the final failure summary. A Java release error usually means the terminal is still using an older JDK.

Return to the Java version tabs if the build does not use JDK 21.

Help me diagnose my Maven Wrapper test failure.

The next command keeps MongoDB attached to its Terminal window so you can watch the startup logs. The first start can take a few minutes while Docker downloads the image.

  • Press Cmd+Space to open macOS search.
  • Type Docker Desktop.
  • Press Enter to start Docker Desktop.
  • Wait until Docker Desktop reports that its engine is running.
  • Press Cmd+N in Terminal to open a second window.
  • Move the second Terminal window into the extracted project by running this command:
cd ~/Desktop/airline-offers-api

Why Use a Second Terminal?

The MongoDB process keeps this Terminal busy while it streams container logs. Your first terminal remains available for Maven commands later.

Before you start the service, make your prediction: which port should MongoDB publish on your Mac? Keep that value in mind.

  • Start the configured MongoDB service by running this command:
docker compose up

What Does This Command Start?

Docker Compose creates the MongoDB container plus its network. It also creates the persistent mongo-data volume.

The published address accepts local connections on port 27017. The running logs let you confirm that MongoDB is waiting for connections.

You will see MongoDB finish its startup sequence before it waits for connections. That is the database signal your Spring application needs.

Does MongoDB Fail to Start?

Confirm that Docker Desktop reports a running engine. Compose cannot create the container while the engine is stopped.

If the logs mention port availability, check whether another local MongoDB process already uses port 27017.

Help me diagnose my MongoDB Compose startup.

Your reproducible Spring baseline now passes its build. Next, you will turn the running MongoDB service into a visible airline offer catalog.

Build the Offer Catalog

Your local MongoDB 9.0.2 database is running. Your API still has no product model to persist or return.

This step builds the first complete path through Spring Boot 4.1.1. A successful REST API request proves that the web layer can reach the database before personalization adds business rules.

In this step, get ready to:
  • Model the airline ancillary catalog with immutable Java records.
  • Persist three sample offers through a MongoDB repository.
  • Return the stored catalog from a public API endpoint.
Define the catalog domain

The catalog needs fixed values for ancillary categories plus loyalty tiers. Enums keep those values consistent across stored documents plus future requests.

  • Select src/main/java/com/example/offers in IntelliJ IDEA's project file tree.
  • Create AncillaryType.java in the com.example.offers package.
  • Define the supported ancillary categories by replacing the generated contents with this code:
package com.example.offers;

public enum AncillaryType {
    BAGGAGE,
    SEAT,
    LOUNGE
}

What Does This Enum Define?

  • BAGGAGE represents checked-bag products.
  • SEAT represents seat-selection products.
  • LOUNGE represents airport lounge products.
  • Save AncillaryType.java.
  • Confirm AncillaryType.java appears under com.example.offers in the project file tree.

Does the Enum Show an Error?

Check that the file starts with package com.example.offers;. Confirm each enum value uses the exact uppercase spelling shown above.

Help me fix my ancillary enum.

  • Create LoyaltyTier.java in the com.example.offers package.
  • Define the supported loyalty tiers by replacing the generated contents with this code:
package com.example.offers;

public enum LoyaltyTier {
    NONE,
    SILVER,
    GOLD
}

What Does This Enum Define?

LoyaltyTier gives the catalog a controlled set of membership levels. Later rules can compare these values without relying on inconsistent free-text labels.

  • Save LoyaltyTier.java.
  • Confirm LoyaltyTier.java appears beside AncillaryType.java.

Does the Loyalty Enum Show an Error?

Confirm the public enum name matches the LoyaltyTier.java filename. Check that every value appears inside the enum braces.

Help me fix my loyalty enum.

An Offer record groups the fields stored for one ancillary product. The validation annotations protect the same structure when administrative creation is added later.

  • Create Offer.java in the com.example.offers package.
  • Define the immutable offer document by replacing the generated contents with this code:
package com.example.offers;

import java.math.BigDecimal;
import java.util.Set;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@Document
public record Offer(
        @Id String id,
        @NotBlank String code,
        @NotBlank String name,
        @NotNull AncillaryType type,
        @NotNull BigDecimal basePrice,
        @NotNull Set<LoyaltyTier> eligibleTiers
) {
}

What Does This Document Store?

  • Document marks the record for MongoDB persistence.
  • id holds the database identifier.
  • code plus name identify the product for API clients.
  • basePrice uses BigDecimal so price calculations remain decimal-based.
  • eligibleTiers records which membership levels may receive the offer.
  • Save Offer.java.
  • Return to the terminal at the extracted project root.
  • Compile the new domain files through the test suite by running:
./mvnw test

What Does This Check?

The generated Maven Wrapper compiles the domain files before running the tests. A successful build confirms that the record imports plus enum references resolve correctly.

Good progress. Maven completes successfully, so your catalog now has a valid domain model.

Does the Domain Model Fail to Compile?

Use the first reported Java file as your starting point. Check its package declaration plus imported class names against the snippets above.

Help me diagnose my domain model.

Persist and seed the catalog

A Spring Data MongoDB repository supplies persistence operations for Offer documents. The seeder uses those operations only when the collection contains no records.

  • Create OfferRepository.java in the com.example.offers package.
  • Connect the offer model to MongoDB by replacing the generated contents with this code:
package com.example.offers;

import org.springframework.data.mongodb.repository.MongoRepository;

public interface OfferRepository extends MongoRepository<Offer, String> {
}

What Does This Repository Provide?

MongoRepository supplies operations such as count(), findAll(), plus saveAll(). The generic types connect those operations to Offer documents with string identifiers.

  • Save OfferRepository.java.
  • Confirm the editor recognizes MongoRepository without an unresolved import.

Is MongoRepository Unresolved?

Confirm OfferRepository.java uses the package com.example.offers. Reload the existing Maven project if IntelliJ IDEA has not indexed the MongoDB starter dependency.

Help me resolve the repository import.

The package also reserves a component for the personalization rules added after the catalog works. This placeholder stays outside the catalog request path for now.

  • Create OfferPersonalizer.java in the com.example.offers package.
  • Add the component placeholder by replacing the generated contents with this code:
package com.example.offers;

import org.springframework.stereotype.Component;

@Component
public class OfferPersonalizer {
}

Why Add This Placeholder?

Component registers OfferPersonalizer with Spring. Its behavior stays empty until the catalog path has proved that persistence plus HTTP work together.

  • Save OfferPersonalizer.java.
  • Confirm IntelliJ IDEA shows no unresolved import in the placeholder.

Does the Placeholder Show an Error?

Check that the filename matches the public class name. Confirm the Component import comes from the Spring stereotype package.

Help me fix the component placeholder.

The data seeder runs during application startup. Its collection check prevents the same three products from being inserted again after every restart.

  • Create OfferDataSeeder.java in the com.example.offers package.
  • Create the compilable seeder shell by replacing the generated contents with this code:
package com.example.offers;

import java.math.BigDecimal;
import java.util.List;
import java.util.Set;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class OfferDataSeeder implements CommandLineRunner {

    private final OfferRepository repository;

    public OfferDataSeeder(OfferRepository repository) {
        this.repository = repository;
    }

    @Override
    public void run(String... args) {
        if (repository.count() == 0) {
        }
    }
}

How Does the Seeder Start?

  • CommandLineRunner makes run() execute after Spring starts the application context.
  • OfferRepository reaches the configured airline_offers database.
  • repository.count() == 0 limits seeding to an empty collection.
  • Save OfferDataSeeder.java.
  • Find the empty if (repository.count() == 0) block inside run().
  • Populate that block by adding this code inside its braces:
            Set<LoyaltyTier> allTiers = Set.of(
                    LoyaltyTier.NONE,
                    LoyaltyTier.SILVER,
                    LoyaltyTier.GOLD
            );
            repository.saveAll(List.of(
                    new Offer(null, "BAG20", "20 kg checked bag", AncillaryType.BAGGAGE,
                            new BigDecimal("60.00"), allTiers),
                    new Offer(null, "SEAT-XL", "Extra-legroom seat", AncillaryType.SEAT,
                            new BigDecimal("45.00"), allTiers),
                    new Offer(null, "LOUNGE", "Departure lounge access", AncillaryType.LOUNGE,
                            new BigDecimal("80.00"), allTiers)
            ));

What Does the Seed Data Create?

  • allTiers makes every sample product available to NONE, SILVER, plus GOLD travelers.
  • BAG20 stores a checked bag priced at 60.00.
  • SEAT-XL stores an extra-legroom seat priced at 45.00.
  • LOUNGE stores departure lounge access priced at 80.00.
  • Save OfferDataSeeder.java.
  • Return to the terminal at the extracted project root.
  • Verify the repository plus seeder compile by running:
./mvnw test

What Does This Test Run Prove?

The test run loads the application context against your running database. The seeder can count the existing documents plus save the three offers when the collection is empty.

Your persistence layer now compiles successfully. The seed guard also keeps future application restarts from duplicating the catalog.

Does the Seeder Test Fail?

Confirm MongoDB is still running through the Compose process from setup. Check that spring.mongodb.uri still points to mongodb://localhost:27017/airline_offers.

Help me diagnose the catalog seeder.

Expose and verify the endpoint

The service layer owns the catalog operation. The controller turns that operation into an HTTP GET /api/offers endpoint.

  • Create OfferService.java in the com.example.offers package.
  • Add the catalog service by replacing the generated contents with this code:
package com.example.offers;

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OfferService {

    private final OfferRepository repository;

    public OfferService(OfferRepository repository) {
        this.repository = repository;
    }

    public List<Offer> catalog() {
        return repository.findAll();
    }
}

What Does the Service Do?

OfferService receives its repository through constructor injection. Its catalog() method delegates the database query to repository.findAll().

  • Save OfferService.java.
  • Confirm IntelliJ IDEA recognizes OfferRepository plus Offer in the service.

Does the Service Show an Error?

Confirm OfferService.java sits in com.example.offers. Check that java.util.List is imported.

Help me fix the catalog service.

  • Create OfferController.java in the com.example.offers package.
  • Expose the catalog route by replacing the generated contents with this code:
package com.example.offers;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/offers")
public class OfferController {

    private final OfferService service;

    public OfferController(OfferService service) {
        this.service = service;
    }

    @GetMapping
    public List<Offer> catalog() {
        return service.catalog();
    }
}

How Does the Request Reach MongoDB?

  • RequestMapping sets the controller's base path to /api/offers.
  • GetMapping maps GET requests at that base path to catalog().
  • service.catalog() keeps database access out of the web layer.
  • Save OfferController.java.
  • Confirm the editor recognizes every Spring web annotation.

Are the Web Annotations Unresolved?

Check that each annotation import comes from org.springframework.web.bind.annotation. Reload the existing Maven project if the web starter has not been indexed.

Help me fix the offer controller.

Use this checkpoint to compare every catalog file created in this step.

✔️ Awesome, I've got everything!

Great. Your domain model, persistence layer, seed data, service, plus controller are ready for the runtime check.

ⓧ I'd like to double check the full code

Compare these complete catalog files with the versions in src/main/java/com/example/offers. The existing project configuration remains unchanged.

package com.example.offers;

public enum AncillaryType {
    BAGGAGE,
    SEAT,
    LOUNGE
}

Ancillary Type Check

This file contains the three supported ancillary categories.

package com.example.offers;

public enum LoyaltyTier {
    NONE,
    SILVER,
    GOLD
}

Loyalty Tier Check

This file contains the three supported loyalty levels.

package com.example.offers;

import java.math.BigDecimal;
import java.util.Set;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@Document
public record Offer(
        @Id String id,
        @NotBlank String code,
        @NotBlank String name,
        @NotNull AncillaryType type,
        @NotNull BigDecimal basePrice,
        @NotNull Set<LoyaltyTier> eligibleTiers
) {
}

Offer Document Check

This file contains the complete immutable MongoDB document used by the catalog.

package com.example.offers;

import org.springframework.data.mongodb.repository.MongoRepository;

public interface OfferRepository extends MongoRepository<Offer, String> {
}

Repository Check

This file exposes MongoDB persistence operations for offers.

package com.example.offers;

import java.math.BigDecimal;
import java.util.List;
import java.util.Set;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class OfferDataSeeder implements CommandLineRunner {

    private final OfferRepository repository;

    public OfferDataSeeder(OfferRepository repository) {
        this.repository = repository;
    }

    @Override
    public void run(String... args) {
        if (repository.count() == 0) {
            Set<LoyaltyTier> allTiers = Set.of(
                    LoyaltyTier.NONE,
                    LoyaltyTier.SILVER,
                    LoyaltyTier.GOLD
            );
            repository.saveAll(List.of(
                    new Offer(null, "BAG20", "20 kg checked bag", AncillaryType.BAGGAGE,
                            new BigDecimal("60.00"), allTiers),
                    new Offer(null, "SEAT-XL", "Extra-legroom seat", AncillaryType.SEAT,
                            new BigDecimal("45.00"), allTiers),
                    new Offer(null, "LOUNGE", "Departure lounge access", AncillaryType.LOUNGE,
                            new BigDecimal("80.00"), allTiers)
            ));
        }
    }
}

Seeder Check

This file inserts the three sample products only when the offer collection is empty.

package com.example.offers;

import org.springframework.stereotype.Component;

@Component
public class OfferPersonalizer {
}

Personalizer Placeholder Check

This file reserves the Spring component used by later contextual offer rules.

package com.example.offers;

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OfferService {

    private final OfferRepository repository;

    public OfferService(OfferRepository repository) {
        this.repository = repository;
    }

    public List<Offer> catalog() {
        return repository.findAll();
    }
}

Service Check

This file delegates catalog retrieval to the repository.

package com.example.offers;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/offers")
public class OfferController {

    private final OfferService service;

    public OfferController(OfferService service) {
        this.service = service;
    }

    @GetMapping
    public List<Offer> catalog() {
        return service.catalog();
    }
}

Controller Check

This file exposes the stored catalog at GET /api/offers.

  • Return to the terminal at the extracted project root.
  • Start the Spring Boot application by running:
./mvnw spring-boot:run

What Does This Command Start?

The Maven Wrapper starts the application with its configured MongoDB connection. Spring creates the repository plus service beans before the seeder checks the offer collection.

The terminal settles with the application running. Keep this process active while you test the endpoint.

Does the Application Stop During Startup?

Confirm the Docker Compose process from setup is still running. Check that port 8080 is not already occupied by another local application.

Help me diagnose application startup.

  • Create another Terminal tab for the catalog request.

Before you send the request, how many products do you expect the repository-backed endpoint to return?

  • Request the public offer catalog by running:
curl http://localhost:8080/api/offers

What Path Does This Request Test?

The request reaches OfferController.catalog(). The controller calls OfferService.catalog() before the repository reads every stored offer.

You will see a JSON array containing three offers. The response includes the BAG20 bag, the SEAT-XL seat, plus the LOUNGE product.

Is the Catalog Empty or Unavailable?

If the request cannot connect, confirm the application process remains active. If the response is empty, inspect the application startup output for a MongoDB connection problem.

Confirm the seeder calls repository.saveAll() inside the empty-collection check. Restart the application after saving any correction.

Help me diagnose the catalog response.

That is your first complete database-backed API path working. Next, you will send traveler context through a search endpoint and expose why a plain catalog cannot personalize an offer.

Expose the Naive Search

Your Spring Boot API already reads the offer catalog from MongoDB. An offer search also needs context about the route plus the traveler.

An HTTP endpoint can accept that context without using it effectively. You will compare two contrasting requests to test whether the returned offers actually change.

In this step, get ready to:
  • Define the validated search request plus the future response contract.
  • Expose a naive search method through the service plus the controller.
  • Compare the offers returned for two contrasting travelers.
Define the search contracts

The search request carries the fields that could influence an offer decision. Bean Validation rejects a request when a required value is missing.

  • Select the com.example.offers package in IntelliJ IDEA's project tree.
  • Create OfferSearchRequest.java inside that package by using the project tree's file creation control.
  • Replace the new file's contents with this record:
package com.example.offers;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record OfferSearchRequest(
        @NotBlank String origin,
        @NotBlank String destination,
        @NotNull LoyaltyTier loyaltyTier,
        @NotBlank String cabin
) {
}

What Does This Record Define?

  • The record groups the route plus traveler context into one request object.
  • The @NotBlank constraints reject missing or empty text values.
  • The @NotNull constraint requires a recognized loyalty tier.
  • Save OfferSearchRequest.java.
  • Confirm IntelliJ IDEA recognizes all four record fields without red error markers.

Seeing Errors in the Request Record?

Confirm the file sits inside src/main/java/com/example/offers. Check that its package remains com.example.offers.

Verify both validation imports match the code above. Help me fix errors in OfferSearchRequest.java.

✔️ Awesome, I've got everything!

Your search request now captures the route plus traveler context with validation constraints.

ⓧ I'd like to double check the full code

package com.example.offers;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record OfferSearchRequest(
        @NotBlank String origin,
        @NotBlank String destination,
        @NotNull LoyaltyTier loyaltyTier,
        @NotBlank String cabin
) {
}

What Should I Compare?

Confirm your package plus imports match this file. Check that each field carries the corresponding validation constraint.

A separate response record defines the shape needed by the upcoming personalization rules. It includes the original price plus the calculated price plus a human-readable reason.

  • Select the com.example.offers package in IntelliJ IDEA's project tree.
  • Create OfferResponse.java inside that package by using the project tree's file creation control.
  • Replace the new file's contents with this record:
package com.example.offers;

import java.math.BigDecimal;

public record OfferResponse(
        String origin,
        String destination,
        String code,
        String name,
        AncillaryType type,
        BigDecimal basePrice,
        BigDecimal finalPrice,
        String reason
) {
}

What Does This Record Define?

  • The route fields preserve the journey that produced each offer.
  • The offer fields describe the ancillary product returned to the client.
  • The finalPrice plus reason fields make a contextual decision visible.
  • Save OfferResponse.java.
  • Confirm IntelliJ IDEA recognizes BigDecimal plus AncillaryType without red error markers.

Seeing Errors in the Response Record?

Check that the BigDecimal import appears below the package declaration. Confirm that AncillaryType.java remains in the same package.

Compare every field name plus type with the code above. Help me fix errors in OfferResponse.java.

✔️ Awesome, I've got everything!

Your response contract can represent an offer with its original price plus its contextual result.

ⓧ I'd like to double check the full code

package com.example.offers;

import java.math.BigDecimal;

public record OfferResponse(
        String origin,
        String destination,
        String code,
        String name,
        AncillaryType type,
        BigDecimal basePrice,
        BigDecimal finalPrice,
        String reason
) {
}

What Should I Compare?

Confirm your file contains the route fields first. Check that basePrice appears before finalPrice.

  • Switch to a Terminal tab at the extracted project folder.
  • Confirm both records compile by running this command:
./mvnw test

What Does This Command Check?

The Maven Wrapper compiles the main source plus the test source. This catches invalid imports plus incompatible field types before the endpoint uses either record.

You will see BUILD SUCCESS. Your two contracts now compile as part of the application.

Does the Build Fail?

Start with the first compilation error in the terminal. Its file path plus line number identify which record needs attention.

Check each package declaration plus import before comparing the record fields. Help me diagnose the Maven compilation failure.

Connect the naive search route

The controller owns the request boundary. The service owns the repository call that supplies the response.

  • Select OfferService.java in IntelliJ IDEA's project tree.
  • Find the closing brace for the existing catalog() method.
  • Add this method directly below catalog():
    public List<Offer> search(OfferSearchRequest request) {
        return repository.findAll();
    }

What Does This Method Do?

  • The method accepts the validated OfferSearchRequest from the controller.
  • The repository returns every persisted Offer document.
  • The current implementation makes the smallest possible search path available for testing.
  • Save OfferService.java.
  • Confirm IntelliJ IDEA resolves OfferSearchRequest plus repository.findAll() without red error markers.

Does the Service Method Show Errors?

Confirm the new method sits inside the OfferService class. Keep it outside the existing catalog() method.

Check that the return type remains List<Offer>. Help me fix the naive search method.

✔️ Awesome, I've got everything!

Your service now exposes catalog retrieval through both catalog() plus search().

ⓧ I'd like to double check the full code

package com.example.offers;

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OfferService {

    private final OfferRepository repository;

    public OfferService(OfferRepository repository) {
        this.repository = repository;
    }

    public List<Offer> catalog() {
        return repository.findAll();
    }

    public List<Offer> search(OfferSearchRequest request) {
        return repository.findAll();
    }
}

What Should I Compare?

Confirm the constructor still accepts only OfferRepository. Check that both public methods delegate to repository.findAll().

The controller now needs a POST mapping for the search path. The @Valid boundary applies the constraints from OfferSearchRequest before the service runs.

  • Select OfferController.java in IntelliJ IDEA's project tree.
  • Add these imports below the existing import block:
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

Why Are These Imports Needed?

  • The Valid import activates the request record's validation constraints.
  • The PostMapping import maps the search method to HTTP POST requests.
  • The RequestBody import converts the incoming JSON body into an OfferSearchRequest.
  • Save OfferController.java.
  • Confirm IntelliJ IDEA recognizes all three imports without red error markers.

Are the Controller Imports Unresolved?

Confirm spring-boot-starter-validation plus spring-boot-starter-webmvc remain in pom.xml.

Reload the Maven project if IntelliJ IDEA has not recognized those existing dependencies. Help me diagnose unresolved controller imports.

  • Find the closing brace for the existing catalog() method.
  • Add this endpoint method directly below catalog():
    @PostMapping("/search")
    public List<Offer> search(@Valid @RequestBody OfferSearchRequest request) {
        return service.search(request);
    }

What Does This Endpoint Do?

  • The @PostMapping("/search") annotation adds the search route below /api/offers.
  • The controller converts the JSON body into the validated request record.
  • The controller delegates the repository-backed result to OfferService.
  • Save OfferController.java.
  • Confirm IntelliJ IDEA resolves service.search(request) without red error markers.

Does the Search Method Show Errors?

Confirm the controller method returns List<Offer>. Check that the service method uses the same return type.

Place the new method inside OfferController after catalog(). Help me fix the controller search method.

✔️ Awesome, I've got everything!

Your controller now serves the catalog through GET plus the naive search through POST.

ⓧ I'd like to double check the full code

package com.example.offers;

import java.util.List;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/offers")
public class OfferController {

    private final OfferService service;

    public OfferController(OfferService service) {
        this.service = service;
    }

    @GetMapping
    public List<Offer> catalog() {
        return service.catalog();
    }

    @PostMapping("/search")
    public List<Offer> search(@Valid @RequestBody OfferSearchRequest request) {
        return service.search(request);
    }
}

What Should I Compare?

Confirm the class still keeps its GET method. Check that the POST method uses @Valid plus @RequestBody on the request parameter.

  • Switch to the Terminal tab running the application.
  • Stop the current application process with Control+C.
  • Restart the application with the new endpoint by running this command:
./mvnw spring-boot:run

What Does This Command Start?

The Maven Wrapper recompiles the changed classes. Spring Boot then starts the web application with the existing MongoDB connection.

The terminal stays attached to the running process. The updated API is ready to receive requests on port 8080.

Does the Application Fail to Restart?

Check the first compilation error for a file path plus line number. A missing controller import or misplaced closing brace prevents startup.

Confirm the Compose process from earlier still has MongoDB running. Help me diagnose the Spring Boot restart.

  • Create a second tab in Terminal for API requests.
  • Confirm the validation boundary by running this incomplete request:
curl -i \
  -H "Content-Type: application/json" \
  -d '{"destination":"JFK","loyaltyTier":"NONE","cabin":"ECONOMY"}' \
  http://localhost:8080/api/offers/search

What Does This Request Check?

The JSON body omits origin. The -i option includes the response headers so you can inspect the status.

The response headers show a 400 status. This proves the controller applies the request record's validation constraints.

Does the Incomplete Request Succeed?

Confirm the controller parameter includes @Valid before @RequestBody. Check that origin has an @NotBlank constraint.

Restart the application after saving any correction. Help me diagnose request validation.

Compare two traveler contexts

Before you run the searches, do you think changing only the loyalty tier will change the offers or their prices?

  • Send an Economy search for a traveler with no loyalty tier by running this command:
curl -H "Content-Type: application/json" \
  -d '{"origin":"LHR","destination":"JFK","loyaltyTier":"NONE","cabin":"ECONOMY"}' \
  http://localhost:8080/api/offers/search

What Does This Request Send?

The request describes an Economy journey from LHR to JFK. The loyalty tier is NONE.

You will see BAG20 at 60.00. You will also see SEAT-XL at 45.00 plus LOUNGE at 80.00.

  • Send the same Economy search for a Gold traveler by running this command:
curl -H "Content-Type: application/json" \
  -d '{"origin":"LHR","destination":"JFK","loyaltyTier":"GOLD","cabin":"ECONOMY"}' \
  http://localhost:8080/api/offers/search

What Changed in This Request?

The route plus cabin remain unchanged. The loyalty tier changes from NONE to GOLD.

You will see the same three offer codes with the same base prices. The Gold context produces no visible difference.

Why Are the Results Identical?

The controller successfully receives both request bodies. The service then calls repository.findAll() for each search.

The request reaches OfferService.search() as a parameter. No rule reads its route plus loyalty fields, so both travelers receive the stored catalog.

Do the Search Requests Fail?

Confirm the application remains running in the first Terminal tab. Check that each JSON body uses double quotes around every field name plus text value.

Verify the request URL ends with /api/offers/search. Help me diagnose the search requests.

You have exposed the exact gap between accepting context and applying it. Next, you will turn those request fields into tested eligibility plus pricing rules.

Personalize and Test Offers

Your naive search proved that traveler context reaches the Spring Boot API. Every traveler still receives the same MongoDB catalog.

Now you will isolate the offer rules in a testable component. JUnit will pin each expected result before the web path uses it.

In this step, get ready to:
  • Write focused tests for Gold pricing plus lounge eligibility.
  • Implement contextual filtering plus loyalty pricing.
  • Connect the personalizer to the search endpoint.
Write the failing personalization tests

A failing test converts the catalog shortfall into a precise requirement. The first test expects a Gold traveler to receive a 20 percent price reduction.

  • Select src/test/java/com/example/offers/OfferPersonalizerTest.java in the IntelliJ IDEA Project panel.
  • Replace the generated test with this first pricing test:
package com.example.offers;

import java.math.BigDecimal;
import java.util.List;
import java.util.Set;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class OfferPersonalizerTest {

    private final OfferPersonalizer personalizer = new OfferPersonalizer();

    @Test
    void appliesGoldPriceToEligibleOffer() {
        Offer bag = new Offer(null, "BAG20", "20 kg checked bag", AncillaryType.BAGGAGE,
                new BigDecimal("100.00"), Set.of(LoyaltyTier.GOLD));
        OfferSearchRequest request = new OfferSearchRequest("LHR", "JFK", LoyaltyTier.GOLD, "ECONOMY");

        List<OfferResponse> result = personalizer.personalize(List.of(bag), request);

        assertEquals(new BigDecimal("80.00"), result.getFirst().finalPrice());
    }
}

What Does This Test Prove?

  • The test creates a baggage offer with a base price of 100.00.
  • The request supplies the GOLD loyalty tier.
  • The final assertion requires the personalizer to return 80.00.
  • Save src/test/java/com/example/offers/OfferPersonalizerTest.java.

Before you run the test, do you expect the current placeholder personalizer to satisfy the Gold price requirement?

  • Run the new pricing test from the project terminal with this command:
./mvnw test

What Does This Command Check?

The generated Maven Wrapper compiles the project. It runs the tests without depending on a separate Maven installation.

The test run fails because the expected personalization behavior is still missing. This planned red result proves the test can detect the pricing gap.

Does the Test Fail for the Wrong Reason?

Check that the test file uses the package com.example.offers. A different package can prevent the project classes from resolving.

Compare the constructor arguments for Offer plus OfferSearchRequest with the code above.

Help me diagnose the failing personalization test.

Pricing covers one dimension of personalization. The second test defines who can see lounge access.

  • Find the final closing brace in OfferPersonalizerTest.java.
  • Add this test directly above that brace:
    @Test
    void excludesLoungeForNonGoldEconomyTraveler() {
        Offer lounge = new Offer(null, "LOUNGE", "Departure lounge access", AncillaryType.LOUNGE,
                new BigDecimal("80.00"), Set.of(LoyaltyTier.NONE));
        OfferSearchRequest request = new OfferSearchRequest("LHR", "JFK", LoyaltyTier.NONE, "ECONOMY");

        List<OfferResponse> result = personalizer.personalize(List.of(lounge), request);

        assertEquals(0, result.size());
    }

What Does the Eligibility Test Prove?

  • The offer is available to the NONE tier at the catalog level.
  • The traveler searches in the ECONOMY cabin.
  • The empty result requires the contextual rule to remove lounge access.

✔️ Awesome, I've got everything!

Your test file now defines the Gold pricing rule plus the Economy lounge rule. Save it before running the complete test suite.

ⓧ I'd like to double check the full code

Compare OfferPersonalizerTest.java with this complete test file.

package com.example.offers;

import java.math.BigDecimal;
import java.util.List;
import java.util.Set;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class OfferPersonalizerTest {

    private final OfferPersonalizer personalizer = new OfferPersonalizer();

    @Test
    void appliesGoldPriceToEligibleOffer() {
        Offer bag = new Offer(null, "BAG20", "20 kg checked bag", AncillaryType.BAGGAGE,
                new BigDecimal("100.00"), Set.of(LoyaltyTier.GOLD));
        OfferSearchRequest request = new OfferSearchRequest("LHR", "JFK", LoyaltyTier.GOLD, "ECONOMY");

        List<OfferResponse> result = personalizer.personalize(List.of(bag), request);

        assertEquals(new BigDecimal("80.00"), result.getFirst().finalPrice());
    }

    @Test
    void excludesLoungeForNonGoldEconomyTraveler() {
        Offer lounge = new Offer(null, "LOUNGE", "Departure lounge access", AncillaryType.LOUNGE,
                new BigDecimal("80.00"), Set.of(LoyaltyTier.NONE));
        OfferSearchRequest request = new OfferSearchRequest("LHR", "JFK", LoyaltyTier.NONE, "ECONOMY");

        List<OfferResponse> result = personalizer.personalize(List.of(lounge), request);

        assertEquals(0, result.size());
    }
}

What Should Match?

The class contains exactly two test methods. Each test creates its own offer plus traveler request.

  • Save src/test/java/com/example/offers/OfferPersonalizerTest.java.

Before you run the suite again, do you expect either rule to pass with the current personalizer?

  • Run both personalization tests with this command:
./mvnw test

What Does the Red Result Mean?

The suite now checks both required rules. Its unsuccessful result gives you a measurable target for the implementation.

The suite remains red because the personalizer does not yet produce the required results. That is the expected starting point for the implementation.

Implement the contextual offer rules

The personalizer owns the business decision for each catalog offer. It filters eligibility before calculating the final price.

Why Keep Rules Outside the Controller?

A controller translates HTTP requests into method calls. A separate personalizer keeps pricing rules usable without starting the web server.

This separation lets the focused unit tests exercise the retail logic directly.

The implementation is split into two sections to keep each code block readable. The first section deliberately leaves the class open. The second section closes it before you save.

  • Select src/main/java/com/example/offers/OfferPersonalizer.java in the IntelliJ IDEA Project panel.
  • Replace the existing contents with this first section:
package com.example.offers;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;

import org.springframework.stereotype.Component;

@Component
public class OfferPersonalizer {

    public List<OfferResponse> personalize(List<Offer> offers, OfferSearchRequest request) {
        return offers.stream()
                .filter(offer -> offer.eligibleTiers().contains(request.loyaltyTier()))
                .filter(offer -> loungeIsEligible(offer, request))
                .map(offer -> toResponse(offer, request))
                .sorted(Comparator.comparing(OfferResponse::finalPrice))
                .toList();
    }

    private boolean loungeIsEligible(Offer offer, OfferSearchRequest request) {
        return offer.type() != AncillaryType.LOUNGE
                || request.loyaltyTier() == LoyaltyTier.GOLD
                || request.cabin().equalsIgnoreCase("BUSINESS");
    }

How Does the Pipeline Choose Offers?

  • The first filter checks whether the catalog offer includes the requested loyalty tier.
  • The second filter keeps every non-lounge offer.
  • The lounge helper also permits Gold travelers plus Business cabin travelers.
  • The comparator places the lowest final price first.
  • Add this second section directly below the lounge helper:
    private OfferResponse toResponse(Offer offer, OfferSearchRequest request) {
        BigDecimal multiplier = switch (request.loyaltyTier()) {
            case GOLD -> new BigDecimal("0.80");
            case SILVER -> new BigDecimal("0.90");
            case NONE -> BigDecimal.ONE;
        };
        BigDecimal finalPrice = offer.basePrice()
                .multiply(multiplier)
                .setScale(2, RoundingMode.HALF_UP);
        String reason = switch (request.loyaltyTier()) {
            case GOLD -> "Gold member price";
            case SILVER -> "Silver member price";
            case NONE -> "Standard traveler price";
        };
        if (offer.type() == AncillaryType.LOUNGE
                && request.cabin().equalsIgnoreCase("BUSINESS")) {
            reason = "Available for Business cabin";
        }
        return new OfferResponse(
                request.origin(),
                request.destination(),
                offer.code(),
                offer.name(),
                offer.type(),
                offer.basePrice(),
                finalPrice,
                reason
        );
    }
}

How Is Each Response Calculated?

  • The loyalty switch selects 0.80 for Gold.
  • The loyalty switch selects 0.90 for Silver.
  • The loyalty switch keeps the full price for the NONE tier.
  • The final calculation rounds the price to two decimal places.
  • The response explains why the traveler received that price.
  • Insert a blank line after the statement ending with RoundingMode.HALF_UP).
  • Insert a blank line before the return new OfferResponse( line.

✔️ Awesome, I've got everything!

Your personalizer now contains the filtering pipeline plus the response conversion logic. Save the file before testing it.

ⓧ I'd like to double check the full code

Compare OfferPersonalizer.java with this complete implementation.

package com.example.offers;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;

import org.springframework.stereotype.Component;

@Component
public class OfferPersonalizer {

    public List<OfferResponse> personalize(List<Offer> offers, OfferSearchRequest request) {
        return offers.stream()
                .filter(offer -> offer.eligibleTiers().contains(request.loyaltyTier()))
                .filter(offer -> loungeIsEligible(offer, request))
                .map(offer -> toResponse(offer, request))
                .sorted(Comparator.comparing(OfferResponse::finalPrice))
                .toList();
    }

    private boolean loungeIsEligible(Offer offer, OfferSearchRequest request) {
        return offer.type() != AncillaryType.LOUNGE
                || request.loyaltyTier() == LoyaltyTier.GOLD
                || request.cabin().equalsIgnoreCase("BUSINESS");
    }

    private OfferResponse toResponse(Offer offer, OfferSearchRequest request) {
        BigDecimal multiplier = switch (request.loyaltyTier()) {
            case GOLD -> new BigDecimal("0.80");
            case SILVER -> new BigDecimal("0.90");
            case NONE -> BigDecimal.ONE;
        };
        BigDecimal finalPrice = offer.basePrice()
                .multiply(multiplier)
                .setScale(2, RoundingMode.HALF_UP);

        String reason = switch (request.loyaltyTier()) {
            case GOLD -> "Gold member price";
            case SILVER -> "Silver member price";
            case NONE -> "Standard traveler price";
        };
        if (offer.type() == AncillaryType.LOUNGE
                && request.cabin().equalsIgnoreCase("BUSINESS")) {
            reason = "Available for Business cabin";
        }

        return new OfferResponse(
                request.origin(),
                request.destination(),
                offer.code(),
                offer.name(),
                offer.type(),
                offer.basePrice(),
                finalPrice,
                reason
        );
    }
}

What Should Match?

The file contains one public personalization method plus two private helpers. The final class brace appears after toResponse.

  • Save src/main/java/com/example/offers/OfferPersonalizer.java.

Before you rerun the tests, do you expect both requirements to turn green now?

  • Test the personalizer implementation with this command:
./mvnw test

What Does This Test Run Confirm?

The suite checks the Gold multiplier plus the lounge exclusion rule. A successful run confirms both rules work without the HTTP layer.

That is the red-to-green turn. Both focused personalization tests now complete with zero failures.

Are the Personalization Tests Still Failing?

Check that the Gold multiplier is 0.80. A value of 0.20 would keep only the discount amount.

Confirm that loungeIsEligible compares the request cabin without case sensitivity.

Help me diagnose the OfferPersonalizer test failures.

Connect personalization to the search endpoint

The passing unit tests prove the rule component works in isolation. The service must now send the persisted catalog plus the request into that component.

  • Select src/main/java/com/example/offers/OfferService.java in the IntelliJ IDEA Project panel.
  • Replace the file with this personalized service implementation:
package com.example.offers;

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OfferService {

    private final OfferRepository repository;
    private final OfferPersonalizer personalizer;

    public OfferService(OfferRepository repository, OfferPersonalizer personalizer) {
        this.repository = repository;
        this.personalizer = personalizer;
    }

    public List<Offer> catalog() {
        return repository.findAll();
    }

    public List<OfferResponse> search(OfferSearchRequest request) {
        return personalizer.personalize(repository.findAll(), request);
    }
}

What Does the Service Coordinate?

  • The constructor receives the repository plus the personalizer.
  • The catalog method keeps returning the stored offers unchanged.
  • The search method loads every offer from the repository.
  • The search method passes the catalog plus traveler context into personalize.

✔️ Awesome, I've got everything!

Your service now delegates search decisions to OfferPersonalizer.

ⓧ I'd like to double check the full code

Compare OfferService.java with this complete service file.

package com.example.offers;

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OfferService {

    private final OfferRepository repository;
    private final OfferPersonalizer personalizer;

    public OfferService(OfferRepository repository, OfferPersonalizer personalizer) {
        this.repository = repository;
        this.personalizer = personalizer;
    }

    public List<Offer> catalog() {
        return repository.findAll();
    }

    public List<OfferResponse> search(OfferSearchRequest request) {
        return personalizer.personalize(repository.findAll(), request);
    }
}

What Should Match?

The service has two dependencies. Its search method returns List<OfferResponse>.

  • Save src/main/java/com/example/offers/OfferService.java.
  • Select src/main/java/com/example/offers/OfferController.java in the Project panel.
  • Replace the existing search method with this method:
    @PostMapping("/search")
    public List<OfferResponse> search(@Valid @RequestBody OfferSearchRequest request) {
        return service.search(request);
    }

What Changes at the HTTP Boundary?

The controller now returns the contextual response contract. Existing Bean Validation still checks the request before the service receives it.

✔️ Awesome, I've got everything!

Your search controller preserves request validation. It now returns personalized offer responses.

ⓧ I'd like to double check the full code

Compare OfferController.java with this complete controller file.

package com.example.offers;

import java.util.List;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/offers")
public class OfferController {

    private final OfferService service;

    public OfferController(OfferService service) {
        this.service = service;
    }

    @GetMapping
    public List<Offer> catalog() {
        return service.catalog();
    }

    @PostMapping("/search")
    public List<OfferResponse> search(@Valid @RequestBody OfferSearchRequest request) {
        return service.search(request);
    }
}

What Should Match?

The catalog endpoint still returns List<Offer>. The search endpoint returns List<OfferResponse>.

  • Save src/main/java/com/example/offers/OfferController.java.

Before you test the connected path, do you expect the service signature plus controller signature to compile together?

  • Verify the connected implementation with this command:
./mvnw test

What Does the Final Test Run Prove?

The command compiles the updated service plus controller. It also reruns both personalization tests.

The build completes successfully with both personalization tests passing. Your web layer now compiles against the contextual response type.

Does the Connected Project Fail to Compile?

Confirm that both search methods return List<OfferResponse>.

Check that the service constructor accepts OfferPersonalizer.

Help me fix the service or controller compilation error.

The automated checks prove the rules directly. The final check proves that real HTTP requests now receive different results.

  • Switch back to the terminal running the API.
  • Stop the current application process with Control+C.
  • Restart the updated API with this command:
./mvnw spring-boot:run

What Does This Command Start?

The application starts the updated HTTP server. Its repository reconnects to the running MongoDB database from earlier.

Give the application a few seconds to finish starting. The terminal remains occupied while the API runs.

Before you repeat the two searches, which offers do you expect the Economy traveler to lose? Which prices do you expect the Gold traveler to receive?

  • Return to the second terminal used for the earlier search requests.
  • Rerun the Economy search with loyalty tier NONE.

You will see two responses ordered by final price. The seat costs 45.00. The bag costs 60.00. Lounge access is absent.

  • Rerun the Economy search with loyalty tier GOLD.

You will see three responses ordered by final price. The seat costs 36.00. The bag costs 48.00. Lounge access costs 64.00.

You have replaced the identical catalog results with visible traveler-specific decisions. The reasons now explain each returned price.

Do Both Travelers Still Receive Identical Results?

Confirm that OfferService.search calls personalizer.personalize.

Check that you restarted the application after saving the updated Java files.

Help me diagnose identical search responses.

Your API now filters eligibility plus calculates loyalty prices. Next, you will protect administrative writes and expose production health signals.

Secure, Monitor, and Automate

Your personalized search now proves that traveler context can change offer eligibility plus pricing. A production-facing Spring Boot service also needs controlled administration plus visible operational health.

A working API remains vulnerable when anyone can create offers. It also needs dependency monitoring plus an automated test run for every code change.

In this step, get ready to:
  • Protect administrative offer creation with HTTP Basic authentication.
  • Expose application health plus authenticated metrics.
  • Run the Maven tests through GitHub Actions on every push.
Protect administrative offer creation

Spring Security places authorization rules in front of your controller methods. Public shoppers keep access to the catalog plus search endpoints while administrative writes require an authenticated role.

  • Select pom.xml in the IntelliJ IDEA project file tree.
  • Find this dependency sequence:
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

What Are You Looking At?

The validation starter supports the constraints already used by your request records. The Actuator starter supplies the operational endpoints that you configure later in this step.

  • Insert the security dependency between the validation plus Actuator dependencies so this section looks like the following code:
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

What Does This Dependency Do?

The security starter adds Spring Security to the application. The pinned Spring Boot parent manages its compatible version under 4.1.1.

  • Save pom.xml.

✔️ Awesome, I've got everything!

Great. Your Maven descriptor now includes the security starter between validation plus Actuator.

ⓧ I'd like to double check the full code

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>airline-offers-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>airline-offers-api</name>
    <description>Context-aware airline ancillary offers API</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Before you run the tests, do you expect the new dependency to change the personalization results?

  • Return to the Terminal window in the airline-offers-api folder.
  • Verify the dependency change by running:
./mvnw test

What Does This Check?

The Maven Wrapper resolves the new managed dependency. It also runs both existing personalization tests against the updated application classpath.

You will see both tests pass followed by BUILD SUCCESS. Your personalization behavior remains intact.

Does Maven Fail to Resolve Security?

Confirm that the new dependency sits inside the existing dependencies element. Check that the artifact name matches spring-boot-starter-security.

Keep the version inherited from the Spring Boot parent. Do not add a separate dependency version.

Help me diagnose the Maven failure after adding Spring Security.

A SecurityFilterChain defines which requests remain public. It also assigns the administrative boundary for offer creation plus metrics.

  • Right-click the offers package in the IntelliJ IDEA project file tree.
  • Select New.
  • Select Java Class.
  • Enter SecurityConfig as the class name.
  • Replace the generated file with this configuration shell:
package com.example.offers;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
}

What Does This Shell Provide?

  • The @Configuration annotation tells Spring that this class defines application components.
  • The imports provide the request authorization plus in-memory user types used by the two configuration methods.
  • Place the following method inside SecurityConfig before its final closing brace:
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(HttpMethod.GET, "/api/offers", "/actuator/health").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/offers/search").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/offers").hasRole("ADMIN")
                        .requestMatchers("/actuator/metrics/**").hasRole("ADMIN")
                        .anyRequest().denyAll()
                )
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

How Are Requests Protected?

  • CSRF protection is disabled because this learning API serves command-line clients instead of browser forms.
  • Catalog reads plus personalized searches remain public.
  • Offer creation plus metrics require the ADMIN role.
  • Every unmatched request is denied by the final rule.
  • Save SecurityConfig.java.
  • Check the security rules compile by running:
./mvnw test

What Does This Run Prove?

The successful test run proves that Spring can build the new security filter chain. It also confirms that your existing JUnit tests still pass.

You will see BUILD SUCCESS again. The authorization rules now compile with the application.

HTTP Basic sends a username plus password with each protected request. The local in-memory user gives you a controlled account for testing those rules.

  • Add this user configuration method below securityFilterChain inside SecurityConfig.
    @Bean
    UserDetailsService users() {
        UserDetails admin = User.builder()
                .username("admin")
                .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
                .roles("USER", "ADMIN")
                .build();
        return new InMemoryUserDetailsManager(admin);
    }

What Does the Admin User Hold?

  • The in-memory user has the username admin.
  • The stored value is a bcrypt password hash for the local demonstration password.
  • The assigned roles allow the account to pass both user-level plus administrative checks.
  • Save SecurityConfig.java.

✔️ Awesome, I've got everything!

Your security configuration now separates public retail requests from protected administrative requests.

ⓧ I'd like to double check the full code

package com.example.offers;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(HttpMethod.GET, "/api/offers", "/actuator/health").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/offers/search").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/offers").hasRole("ADMIN")
                        .requestMatchers("/actuator/metrics/**").hasRole("ADMIN")
                        .anyRequest().denyAll()
                )
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    UserDetailsService users() {
        UserDetails admin = User.builder()
                .username("admin")
                .password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
                .roles("USER", "ADMIN")
                .build();
        return new InMemoryUserDetailsManager(admin);
    }
}

The controller needs a write path before the authorization rule has anything to protect. The service creates a fresh MongoDB document by removing any caller-supplied identifier.

  • Select src/main/java/com/example/offers/OfferService.java in the IntelliJ IDEA project file tree.
  • Add this method below search inside OfferService.
    public Offer create(Offer offer) {
        Offer unsavedOffer = new Offer(
                null,
                offer.code(),
                offer.name(),
                offer.type(),
                offer.basePrice(),
                offer.eligibleTiers()
        );
        return repository.save(unsavedOffer);
    }

What Does the Create Method Do?

The method copies the validated offer fields into a new Offer with a null identifier. MongoDB assigns the stored document identifier when the repository saves it.

  • Save OfferService.java.
  • Confirm the service still compiles by running:
./mvnw test

What Does This Verify?

The test run compiles the new service method. It also repeats the two focused personalization checks.

You will see BUILD SUCCESS. The service can now persist an administrative offer.

✔️ Awesome, I've got everything!

Your service now supports catalog reads plus personalized searches plus controlled creation.

ⓧ I'd like to double check the full code

package com.example.offers;

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OfferService {

    private final OfferRepository repository;
    private final OfferPersonalizer personalizer;

    public OfferService(OfferRepository repository, OfferPersonalizer personalizer) {
        this.repository = repository;
        this.personalizer = personalizer;
    }

    public List<Offer> catalog() {
        return repository.findAll();
    }

    public List<OfferResponse> search(OfferSearchRequest request) {
        return personalizer.personalize(repository.findAll(), request);
    }

    public Offer create(Offer offer) {
        Offer unsavedOffer = new Offer(
                null,
                offer.code(),
                offer.name(),
                offer.type(),
                offer.basePrice(),
                offer.eligibleTiers()
        );
        return repository.save(unsavedOffer);
    }
}
  • Select src/main/java/com/example/offers/OfferController.java in the IntelliJ IDEA project file tree.
  • Find the current import section beginning with jakarta.validation.Valid.
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

What Is Missing Here?

The current imports support request validation plus route mappings. The create endpoint also needs an HTTP status type plus a response status annotation.

  • Replace that import section with the following target version:
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

Why Add These Imports?

The two imports let the controller return HTTP 201 when MongoDB stores a new offer. That status distinguishes successful creation from a normal read response.

  • Add this method below search inside OfferController.
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Offer create(@Valid @RequestBody Offer offer) {
        return service.create(offer);
    }

How Does the Endpoint Behave?

  • The class-level route makes this method handle POST /api/offers.
  • The @Valid annotation checks the incoming offer before the service receives it.
  • The response status annotation returns HTTP 201 after the repository saves the offer.
  • Save OfferController.java.

✔️ Awesome, I've got everything!

Your controller now exposes a validated administrative creation endpoint.

ⓧ I'd like to double check the full code

package com.example.offers;

import java.util.List;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/offers")
public class OfferController {

    private final OfferService service;

    public OfferController(OfferService service) {
        this.service = service;
    }

    @GetMapping
    public List<Offer> catalog() {
        return service.catalog();
    }

    @PostMapping("/search")
    public List<OfferResponse> search(@Valid @RequestBody OfferSearchRequest request) {
        return service.search(request);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Offer create(@Valid @RequestBody Offer offer) {
        return service.create(offer);
    }
}
  • Return to the Terminal window running the application.
  • Stop the current process by pressing Ctrl+C.
  • Start the secured application by running:
./mvnw spring-boot:run

Why Restart the Application?

The restart loads the new dependency plus security filter chain. It also registers the administrative controller method.

You will see the application start on port 8080 while the MongoDB container remains available.

Before you send an unauthenticated offer, do you expect the request to reach OfferController.create?

  • Return to your second Terminal window.
  • Send an offer without credentials by running:
curl -i -H "Content-Type: application/json" -d '{"code":"LOUNGE","name":"Departure lounge access","type":"LOUNGE","basePrice":80.00,"eligibleTiers":["NONE","SILVER","GOLD"]}' http://localhost:8080/api/offers

What Does This Request Test?

The JSON body is valid enough to reach the protected route. The missing HTTP Basic credentials let the security filter reject it before the controller creates a document.

You will see a response status containing 401. That is the intended proof that anonymous callers cannot create offers.

Does the Request Avoid the 401 Response?

Confirm that the request uses POST /api/offers instead of the public search route. Check that the POST matcher for /api/offers requires the ADMIN role.

Confirm that the application was restarted after you saved SecurityConfig.java.

Help me diagnose why unauthenticated offer creation is not returning 401.

Expose operational endpoints

Spring Boot Actuator exposes operational information without adding custom controllers. The health response can prove that the application still reaches MongoDB.

  • Select src/main/resources/application.properties in the IntelliJ IDEA project file tree.
  • Find the current application plus MongoDB properties:
spring.application.name=airline-offers-api
spring.mongodb.uri=mongodb://localhost:27017/airline_offers

What Do These Properties Control?

The first property names the application. The second connects the service to the local airline offers database.

  • Add the two management properties so the complete file looks like this:
spring.application.name=airline-offers-api
spring.mongodb.uri=mongodb://localhost:27017/airline_offers
management.endpoints.web.exposure.include=health,metrics
management.endpoint.health.show-details=always

What Does the Actuator Configuration Expose?

  • The exposure list limits the web endpoints to health plus metrics.
  • The detail setting includes individual dependency results in the health response.
  • The security configuration keeps health public while protecting metrics with the ADMIN role.
  • Save application.properties.

✔️ Awesome, I've got everything!

Your application now exposes detailed health plus protected metrics.

ⓧ I'd like to double check the full code

spring.application.name=airline-offers-api
spring.mongodb.uri=mongodb://localhost:27017/airline_offers
management.endpoints.web.exposure.include=health,metrics
management.endpoint.health.show-details=always
  • Return to the Terminal window running the application.
  • Stop the current process by pressing Ctrl+C.
  • Reload the Actuator configuration by running:
./mvnw spring-boot:run

What Changes After the Restart?

The restart applies the endpoint exposure settings. Actuator also registers the MongoDB health contributor from the existing data dependency.

Before you check health, do you expect the API to report an available database or a failed dependency?

  • Return to your second Terminal window.
  • Check application health by running:
curl http://localhost:8080/actuator/health

What Should the Health Response Prove?

The response reports the overall status plus individual components. You will see UP for the application plus its MongoDB connection.

  • Request the protected metrics index with the local administrator credentials by running:
curl -u admin:password http://localhost:8080/actuator/metrics

What Does the Authenticated Request Prove?

The credentials satisfy HTTP Basic authentication plus the required administrative role. You will see a response containing the available metric names.

That is two production signals working together. Health shows dependency availability while metrics require an authorized operator.

Is Health Down or Metrics Unauthorized?

If health reports a failed MongoDB component, confirm that the Compose process from earlier still shows the database waiting for connections. Keep the local port bound to 27017.

If metrics returns 401 or 403, check the username plus password. Confirm that the in-memory account has the ADMIN role.

Help me diagnose my Actuator health or metrics response.

Run tests on every push

Continuous integration runs the same verification from a clean environment whenever code reaches the repository. GitHub Actions provides that repeatable check for your public project.

  • Right-click the airline-offers-api project in the IntelliJ IDEA project file tree.
  • Create the .github directory inside airline-offers-api.
  • Create the workflows directory inside .github.
  • Create ci.yml inside .github/workflows.
  • Add this workflow to .github/workflows/ci.yml.
name: Java CI

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-java@v4
        with:
          java-version: "21"
          distribution: temurin
          cache: maven
      - name: Run Maven verification
        run: mvn --batch-mode --update-snapshots verify

How Does the Workflow Verify Your Project?

  • The workflow starts for pushes plus pull requests.
  • The checkout action loads the repository onto a standard Ubuntu runner.
  • The Java setup action installs Temurin Java 21 plus Maven dependency caching.
  • The final step runs Maven verification against the committed project.
  • Save .github/workflows/ci.yml.

✔️ Awesome, I've got everything!

Your repository now contains a Java CI workflow for every push plus pull request.

ⓧ I'd like to double check the full code

name: Java CI

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-java@v4
        with:
          java-version: "21"
          distribution: temurin
          cache: maven
      - name: Run Maven verification
        run: mvn --batch-mode --update-snapshots verify

Git captures the local project as a commit before GitHub can run the workflow. This first snapshot includes the secured API plus its operational configuration.

  • Return to the Terminal window in the airline-offers-api folder.
  • Initialize the repository plus create its first commit by running:
git init -b main
git add .
git commit -m "First commit"

What Do These Git Commands Create?

The first command creates a repository whose initial branch is main. The next commands stage the project plus record its first snapshot.

You will see a new commit summary listing the project files. The workflow is now part of the versioned codebase.

Does Git Reject the Commit?

If Git asks for your identity, follow the displayed guidance to configure your author name plus email. Run the commit again after that configuration is saved.

If the directory is already a repository, keep its current history. Confirm that the workflow appears in the staged file list.

Help me diagnose why the first Git commit failed.

The next actions create a public repository. Your source code becomes visible on the internet, so confirm that no credentials or private files are present before you push.

  • Return to your signed-in GitHub account.
  • Open the menu in the upper-right corner.
  • Select New repository.
  • Enter airline-offers-api in the Repository name field.
  • Select Public as the repository visibility.
  • Leave the repository initialization options unselected.
  • Click Create repository.
  • Copy the remote URL from the Quick setup page into https://github.com/your-username/airline-offers-api.git.

GitHub may ask you to authenticate through your browser during the first push. Complete that prompt so Git can publish the commit without storing a password in this project.

Before you push, do you expect the clean GitHub runner to reproduce the successful local Maven tests?

  • Return to the Terminal window in the airline-offers-api folder.
  • Connect the public repository plus push the main branch by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/airline-offers-api.git"]]
git push -u origin main

What Happens After the Push?

The remote named origin points at your public GitHub repository. The push publishes the commit plus triggers the workflow because the workflow listens for pushes.

The terminal confirms that the main branch now tracks its remote counterpart.

  • Return to the new airline-offers-api repository on GitHub.
  • Select the Actions tab.
  • Select the newest Java CI workflow run.
  • Open the test job.
  • Confirm that Run Maven verification has a green success indicator.

That completes the production-ready layer of your learning API. Administrative writes are protected, MongoDB health is visible, plus every pushed change now proves the tests still pass.

Secret mission

Add Deterministic A/B Offer Variants

Assign each traveler consistently to experiment A or B using traveler identity plus route context. Variant B applies a five percent seat treatment without changing the existing loyalty rules.

Clean Up Your Resources

Clean Up Your Resources

Your project can stay ready for demos, pause until your next session, or be removed completely. The current personal-learning setup costs $0 because its public repository uses standard GitHub-hosted runners.

Resources you used:

  • The running Spring Boot API process that serves the airline offers.
  • The Docker Compose mongo service running MongoDB 9.0.2.
  • The persistent mongo-data named volume containing the seeded offers.
  • The local airline-offers-api Maven project.
  • The public GitHub repository containing the project history.
  • The GitHub Actions workflow stored in .github/workflows/ci.yml.

Keep everything running

No action needed. Choose this if you are still demonstrating the personalized offers or experimenting with A/B variants.

  • Leave the Spring Boot application process active in its Terminal window.
  • Leave the Docker Compose process active in its second Terminal window.
  • Keep the airline-offers-api folder for future development.
  • Keep the public GitHub repository for future commits.
  • Keep the workflow on standard GitHub-hosted runners.
  • Avoid storing unnecessary workflow artifacts.

Pause - I'll come back to this later

Shut down the application and database processes to free their local ports. Your source files, database volume, and public repository remain ready for the next session.

  • Press Control+C in the Terminal window running the Spring Boot application.
  • Switch back to the second Terminal window running Docker Compose.
  • Press Control+C to return to the command prompt.
  • Preserve the named volume while stopping MongoDB by running this command:
docker compose stop

What This Keeps

This command stops the mongo service. The mongo-data volume stays on disk so your seeded offers return next time.

  • Switch back to Docker Desktop.
  • Confirm that the mongo container is stopped.

Your API and database are now paused. The code and seeded catalog remain ready for another session.

Delete - I don't want to use this again

Deleting this project is permanent, so choose this path only when you no longer need the demo or its repository history. Your installed development tools remain available for future projects.

  • Press Control+C in the Terminal window running the Spring Boot application.
  • Switch back to the second Terminal window running Docker Compose.
  • Press Control+C to return to the command prompt.
  • Remove the Compose resources and named volume by running this command:
docker compose down -v

What This Removes

This command removes the MongoDB container and its Compose network. The -v flag also removes the named mongo-data volume containing your stored offers.

  • Switch back to Docker Desktop.
  • Confirm that the mongo container no longer appears.
  • Confirm that the mongo-data volume no longer appears.
Delete the Local Project
  • Close IntelliJ IDEA.
  • In Finder, return to the folder containing airline-offers-api.
  • Move the airline-offers-api folder to Trash.
  • Confirm that airline-offers-api no longer appears in Finder.

The local source code, Maven Wrapper, and project configuration are now removed.

Delete the GitHub Repository
  • Return to the public GitHub repository for airline-offers-api in your browser.
  • Open the repository settings page.
  • Move to the area that controls repository deletion.
  • Use the repository deletion control to remove the repository.
  • Refresh the former repository URL.

You'll see that the repository no longer loads. That completes the cleanup across Docker, your Mac, and GitHub.

Nice Work!

Nice Work!

You did it! You built an airline ancillary offer API with Spring Boot. It returns ranked contextual offers from MongoDB with production-style safeguards.

You've learned how to:

  • Build a MongoDB-backed REST API that seeds a catalog of airline ancillaries. Fetch the persisted catalog through a public endpoint.
  • Expose the limits of a naive catalog search. Replace it with contextual offer rules that produce ranked responses for each traveler. Reject incomplete input through Bean Validation. Prove personalization behavior with JUnit tests.
  • Protect administrative writes through HTTP Basic authorization. Monitor dependency health with Spring Boot Actuator. Run Maven verification on every push through GitHub Actions.
  • Secret Mission: Assign each traveler to a stable A/B variant using a deterministic context hash. Apply variant B's 5 percent treatment only to seat offers. Prove assignment stability without changing loyalty behavior.

Ready to quiz yourself?