Build an Ecommerce Search API
Build a Kotlin API for product search using Amazon OpenSearch Serverless.
Introduction
30 Second Summary
Shoppers expect a search box to understand what they meant when a product name is misspelled. A literal text scan turns that small typo into an empty shelf.
In this project, you will build an ecommerce product search API with Spring Boot plus Kotlin. Amazon OpenSearch Serverless powers typo-tolerant ranked results for the catalog.
What You'll Build
A browser request for http://localhost:8080/api/products/search?q=earbds&category=electronics returns Wireless Earbuds with an OpenSearch score.
By the end of this project, you'll have:
- A typo-tolerant search demo that finds Wireless Earbuds when the query is misspelled as earbds.
- An exact category filter that narrows the response to electronics products without changing text relevance.
- Category facet counts that show how many matches belong to each storefront filter option.
- Secret Mission: Add exact brand filtering plus brand facet counts for a second drill-down dimension.
Are there any prerequisites?
You need a Windows computer with IntelliJ IDEA. You also need an AWS account with permission to create Amazon OpenSearch Serverless collections.
The project guides you through every unconfirmed local dependency.
Before We Start
This first checkpoint locks in one trustworthy boundary: the ecommerce catalog remains the source of truth. Amazon OpenSearch Serverless serves as a derived read model for typo-tolerant search, relevance ranking, exact filters, and facets.
Set Up the Windows Toolchain
Your ecommerce API needs a standalone Java Development Kit to compile and run. IntelliJ IDEA uses its bundled runtime to power the editor.
You will configure JDK 21. You will also connect your local AWS identity.
Spring Initializr then generates a Gradle-based Kotlin Spring Boot project that you can run locally.
In this step, get ready to:
- Configure JDK 21 in IntelliJ IDEA.
- Install AWS CLI version 2 and configure your local AWS credentials.
- Generate the ecommerce-search project and run its embedded server.
Install JDK 21 in IntelliJ IDEA
The JDK provides the compiler that turns Kotlin source code into runnable application code. IntelliJ IDEA can download the standalone JDK without a separate installer.
- Press the Windows key to open Windows search.
- Type IntelliJ IDEA into the search field.
- Press Enter to open IntelliJ IDEA.
- Click File in the top menu.
- Click Project Structure.
- Select SDKs under Platform Settings.
✔️ JDK 21 is listed
JDK 21 is already available to IntelliJ IDEA.
- Confirm the listed JDK reports version 21.
ⓧ An older JDK is listed
Your older JDK can stay installed. IntelliJ IDEA supports adding JDK 21 beside it.
- Click Download JDK in the SDK toolbar.
- Select version 21.
- Confirm the download.
You should see JDK 21 appear in the SDK list when the download finishes.
ⓧ No JDK is listed
IntelliJ IDEA needs a standalone JDK before it can build the project.
- Click Download JDK in the SDK toolbar.
- Select version 21.
- Confirm the download.
You should see JDK 21 appear in the SDK list when the download finishes.
That is the Java foundation in place. IntelliJ IDEA can now use JDK 21 when you open the generated project.
JDK 21 Not Appearing?
- Return to SDKs under Platform Settings.
- Retry Download JDK after checking your internet connection.
Share what appears in your SDK list through help me configure JDK 21 in IntelliJ IDEA.
Install and configure AWS CLI version 2
PowerShell gives you a direct way to install the AWS CLI. The CLI stores the local identity that your application uses for signed AWS requests later.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open PowerShell.
- Check the installed AWS CLI version by running this command:
aws --version
What Does This Command Do?
The command asks the AWS CLI to print its installed version. The result tells you whether version 2 is ready or needs to be installed.
✔️ I see AWS CLI version 2
AWS CLI version 2 is ready for credential configuration.
ⓧ I see an older version
- Install AWS CLI version 2 by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex
What Does This Command Do?
This official installer downloads AWS CLI version 2 for supported 64-bit Windows systems. It replaces the older command installation used by your current PowerShell session.
- Close PowerShell after the installer completes.
- Open a new PowerShell window through Windows search.
- Verify the new installation by running this command:
aws --version
What Does This Check?
Opening a new PowerShell window reloads the available commands. The version check confirms that Windows now resolves the AWS CLI version 2 installation.
You should now see AWS CLI version 2 in the output.
ⓧ Command not found
- Install AWS CLI version 2 by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex
What Does This Command Do?
This official installer adds AWS CLI version 2 to your Windows system.
- Close PowerShell after the installer completes.
- Open a new PowerShell window through Windows search.
- Verify the installation by running this command:
aws --version
What Does This Check?
The version check confirms that the new PowerShell session can find the AWS CLI installation.
You should now see AWS CLI version 2 in the output.
AWS CLI Still Not Available?
- Confirm that the installation command completed without a network failure.
- Restart PowerShell before retrying the version check.
Use help me troubleshoot the AWS CLI installation to inspect the output you received.
AWS credentials link local commands to the permissions granted by your account. Use the configuration method supported by your AWS administrator.
IAM Identity Center
The setup asks for values supplied by your organization. It can also open a browser so you can authorize the CLI session.
- Gather the IAM Identity Center start URL supplied by your AWS administrator.
- Start credential configuration by running this command:
aws configure sso
What Does This Command Do?
The command connects the AWS CLI to your IAM Identity Center session. Saving the profile as the default lets later AWS commands use it without an explicit profile selector.
- Enter the start URL supplied by your AWS administrator.
- Enter the AWS Region supplied by your AWS administrator.
- Complete the browser sign-in when it opens.
- Select the AWS account you use for this project.
- Select the role that has access to your development account.
- Enter default when the setup asks for a profile name.
Another AWS Credential Method
Some AWS accounts use a credential method managed by an administrator. Follow the setup instructions issued for your account.
- Complete the credential setup supplied by your AWS administrator.
- Store the configured identity in the default AWS CLI profile.
Keep Credentials Out of Code
Store credentials through the approved AWS configuration method. Your application can read the default local profile without placing credentials in project files.
Before you test the credentials, predict what information a successful identity response should reveal about the signed-in principal.
- Verify the configured AWS identity by running this command:
aws sts get-caller-identity
What Does This Command Prove?
The command asks AWS to identify the principal behind your current credentials. A successful response proves that the CLI can authenticate with your default profile.
You should see a response containing your AWS account and ARN. That is the identity your application can use for signed requests.
Identity Check Not Working?
- Confirm that the credential setup completed in the same Windows account.
- Repeat your approved sign-in flow if the local session has expired.
Use help me diagnose my AWS identity check without sharing access keys or tokens.
Generate and run the Spring Boot project
Spring Initializr creates the Gradle wrapper plus the application shell in one ZIP. The generated wrapper keeps the project tied to a compatible Gradle release.
- Open your web browser.
- Enter https://start.spring.io in the address bar.
- Select Gradle - Kotlin for the project type.
- Select Kotlin as the language.
- Select Spring Boot version 4.1.1.
- Enter com.example in the Group field.
- Enter ecommerce-search in the Artifact field.
- Enter ecommerce-search in the Name field.
Why These Project Values?
The artifact and name keep the generated files aligned with the ecommerce-search project. The group becomes the base namespace for your Kotlin code.
- Enter com.example.search in the Package name field.
- Select Jar for packaging.
- Select Java version 21.
- Click Add Dependencies.
- Search for Spring Web MVC.
- Select Spring Web MVC from the results.
- Click Generate to download the ZIP.
What Did Spring Initializr Create?
The ZIP contains the application entry point plus Gradle configuration files. It also contains the Gradle wrapper that lets the project use its expected build tooling.
- Press the Windows key to open Windows search.
- Type File Explorer into the search field.
- Press Enter to open File Explorer.
- Open your Downloads folder.
- Select ecommerce-search.zip.
- Choose the extraction option from the File Explorer toolbar.
- Extract the ZIP into your Desktop.
Your Desktop should now contain an ecommerce-search folder. The folder contains the generated Gradle wrapper plus the Kotlin application.
- Return to IntelliJ IDEA.
- Click File in the top menu.
- Click Open.
- Select the ecommerce-search folder on your Desktop.
- Confirm the folder selection.
- Approve the project trust prompt if IntelliJ IDEA displays one.
The first Gradle sync can take a few minutes while dependencies download. A quiet progress indicator during this first import is normal.
- Confirm that the Project tool window lists gradlew plus the src folder.
- Click File in the top menu.
- Click Project Structure.
- Select Project under Project Settings.
- Choose JDK 21 as the project SDK.
- Click Apply.
- Click OK.
- Compare the generated files with the project checkpoint below.
✔️ Awesome, I've got everything!
Your generated project files match the checkpoint. The application is ready for its first run.
ⓧ I'd like to double check the full code
The build.gradle.kts file should contain:
plugins {
kotlin("jvm") version "2.4.20"
kotlin("plugin.spring") version "2.4.20"
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.4.20")
}
What Does This File Configure?
This file pins the Kotlin and Spring Boot plugins. It also adds the web starter plus Kotlin reflection support.
The settings.gradle.kts file should contain:
rootProject.name = "ecommerce-search"
What Does This File Configure?
This file gives the Gradle project its ecommerce-search name.
The src/main/kotlin/com/example/search/EcommerceSearchApplication.kt file should contain:
package com.example.search
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class EcommerceSearchApplication
fun main(args: Array<String>) {
runApplication<EcommerceSearchApplication>(*args)
}
What Does This File Do?
This is the generated application entry point. Its main function starts Spring Boot with the application configuration.
The src/main/resources/application.properties file should contain:
spring.application.name=ecommerce-search
What Does This File Configure?
This property gives the running Spring Boot application its ecommerce-search name.
- Replace the contents of any generated file that differs from its checkpoint.
Before you run the application, picture the status you expect to see in the IntelliJ IDEA Run window.
- Expand src/main/kotlin/com/example/search in the Project tool window.
- Open EcommerceSearchApplication.kt.
- Click the green run icon beside main.
- Choose the generated application option if IntelliJ IDEA shows a run menu.
You should see the Run window report that the embedded server started on port 8080. That is your first working Spring Boot process.
Your Windows toolchain can now compile the project and authenticate with AWS. Next up, you will add a product catalog and expose the weakness of basic local search.
Expose the Limits of Local Search
Your Spring Boot application now starts on port 8080. This gives you a working shell for the product API.
Search quality becomes visible when a shopper's wording drifts from the catalog text. This step gives you a lowercase substring search to test against a realistic product catalog.
In this step, get ready to:
- Define the product data model.
- Populate an in-memory catalog with 12 products.
- Compare exact substring search with a misspelled query.
Model the product catalog
The search endpoint needs a consistent shape for every item it returns. The product model captures the fields a storefront needs for display plus filtering.
- In the Project tool window in IntelliJ IDEA, select src/main/kotlin.
- Press Alt+Insert to open the creation menu.
- Select Package.
- Enter com.example.search.product as the package name.
- Press Enter to create the package.
You should see the new com.example.search.product package beneath src/main/kotlin.
- Select the com.example.search.product package.
- Press Alt+Insert.
- Select File.
- Enter Product.kt as the file name.
- Press Enter to create the file.
- Define the product model by replacing the empty file with this code:
package com.example.search.product
data class Product(
var id: String = "",
var name: String = "",
var description: String = "",
var category: String = "",
var brand: String = "",
var price: Double = 0.0,
var inStock: Boolean = false
)
What Does This Model Capture?
- The Product model gives every catalog item the same searchable fields.
- The category plus brand fields support exact filters later in the project.
- The price plus stock fields preserve values that a storefront can display.
- Save Product.kt.
- Confirm that Product.kt appears inside the com.example.search.product package.
Seeing Errors in Product.kt?
- Confirm that the first line uses the package com.example.search.product.
- Check that every property remains inside the parentheses for Product.
Help me fix errors in my Product model.
The model defines one product. The catalog now supplies the 12 records that make the search behavior visible.
- Select the com.example.search.product package.
- Press Alt+Insert.
- Select File.
- Enter ProductCatalog.kt as the file name.
- Press Enter to create the file.
- Populate the catalog by replacing the empty file with this code:
package com.example.search.product
import org.springframework.stereotype.Component
@Component
class ProductCatalog {
val products = listOf(
Product("p-001", "Wireless Earbuds", "Noise-isolating Bluetooth earbuds with a charging case", "electronics", "sonicwave", 79.99, true),
Product("p-002", "Studio Headphones", "Over-ear wired headphones for detailed audio monitoring", "electronics", "sonicwave", 129.99, true),
Product("p-003", "Portable Speaker", "Water-resistant wireless speaker for travel", "electronics", "soundtrail", 59.99, true),
Product("p-004", "Mechanical Keyboard", "Compact keyboard with tactile switches", "electronics", "keyforge", 94.99, false),
Product("p-005", "Pour Over Kettle", "Temperature-control kettle with a precision spout", "home", "brewcraft", 69.99, true),
Product("p-006", "Ceramic Mug Set", "Four stackable mugs for coffee and tea", "home", "brewcraft", 34.99, true),
Product("p-007", "Air Purifier", "Quiet purifier for bedrooms and small offices", "home", "cleanair", 149.99, false),
Product("p-008", "Desk Lamp", "Adjustable LED lamp with warm and cool modes", "home", "lumina", 44.99, true),
Product("p-009", "Yoga Mat", "Non-slip exercise mat with carrying strap", "fitness", "movefree", 39.99, true),
Product("p-010", "Resistance Bands", "Five-band strength training set", "fitness", "movefree", 24.99, true),
Product("p-011", "Insulated Bottle", "Stainless steel bottle for hot or cold drinks", "fitness", "trailflow", 29.99, true),
Product("p-012", "Foam Roller", "Textured recovery roller for mobility work", "fitness", "movefree", 31.99, false)
)
}
How Is the Catalog Structured?
- The component annotation makes the catalog available to the search controller through constructor injection.
- The list contains four electronics products.
- The list contains four home products.
- The list contains four fitness products.
- Save ProductCatalog.kt.
- Stop the previous application process if it is still active in the Run window.
- Run EcommerceSearchApplication by clicking the green run icon beside main().
You should see the application start successfully on port 8080. This confirms that all 12 products compile into the application.
Does the Application Fail to Start?
- Confirm that Product.kt shares the same package as ProductCatalog.kt.
- Check every catalog row for matching quotation marks plus a closing parenthesis.
- Compare the number values against the catalog code above.
Help me troubleshoot the catalog compilation failure.
Expose local substring search
A local controller can scan the catalog without any external service. Lowercase substring matching gives the first version a simple rule that is easy to test.
- Select the com.example.search.product package.
- Press Alt+Insert.
- Select File.
- Enter LocalSearchController.kt as the file name.
- Press Enter to create the file.
- Expose the temporary search endpoint by replacing the empty file with this code:
package com.example.search.product
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/products")
class LocalSearchController(private val catalog: ProductCatalog) {
@GetMapping("/search")
fun search(@RequestParam q: String): List<Product> {
val query = q.lowercase()
return catalog.products.filter {
it.name.lowercase().contains(query) || it.description.lowercase().contains(query)
}
}
}
What Does This Controller Do?
- The controller publishes the /api/products/search endpoint.
- The q parameter supplies the shopper's search text.
- Lowercase conversion removes capitalization differences from the comparison.
- The filter keeps products whose names or descriptions contain the query.
- Save LocalSearchController.kt.
- Restart EcommerceSearchApplication from the Run window.
You should see the application start on port 8080 again. The new endpoint is now ready to receive browser requests.
Does the Endpoint Fail to Load?
- Confirm that LocalSearchController.kt uses the com.example.search.product package.
- Confirm that the application restarted after you saved the controller.
- Check that another process is not already using port 8080.
Help me troubleshoot the local search endpoint.
✔️ Awesome, I've got everything!
Great. Keep the application running while you test both search cases.
ⓧ I'd like to double check the full code
Compare each project file with the matching reference below.
plugins {
kotlin("jvm") version "2.4.20"
kotlin("plugin.spring") version "2.4.20"
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.4.20")
}
What Should Match Here?
The build file retains the generated web dependency plus Kotlin reflection dependency.
rootProject.name = "ecommerce-search"
What Should Match Here?
The settings file keeps the project name as ecommerce-search.
package com.example.search
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class EcommerceSearchApplication
fun main(args: Array<String>) {
runApplication<EcommerceSearchApplication>(*args)
}
What Should Match Here?
The generated application class remains the entry point for the local server.
package com.example.search.product
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/products")
class LocalSearchController(private val catalog: ProductCatalog) {
@GetMapping("/search")
fun search(@RequestParam q: String): List<Product> {
val query = q.lowercase()
return catalog.products.filter {
it.name.lowercase().contains(query) || it.description.lowercase().contains(query)
}
}
}
What Should Match Here?
The temporary controller lowercases the query before scanning product names plus descriptions.
package com.example.search.product
data class Product(
var id: String = "",
var name: String = "",
var description: String = "",
var category: String = "",
var brand: String = "",
var price: Double = 0.0,
var inStock: Boolean = false
)
What Should Match Here?
The product model contains the seven fields used throughout the catalog.
package com.example.search.product
import org.springframework.stereotype.Component
@Component
class ProductCatalog {
val products = listOf(
Product("p-001", "Wireless Earbuds", "Noise-isolating Bluetooth earbuds with a charging case", "electronics", "sonicwave", 79.99, true),
Product("p-002", "Studio Headphones", "Over-ear wired headphones for detailed audio monitoring", "electronics", "sonicwave", 129.99, true),
Product("p-003", "Portable Speaker", "Water-resistant wireless speaker for travel", "electronics", "soundtrail", 59.99, true),
Product("p-004", "Mechanical Keyboard", "Compact keyboard with tactile switches", "electronics", "keyforge", 94.99, false),
Product("p-005", "Pour Over Kettle", "Temperature-control kettle with a precision spout", "home", "brewcraft", 69.99, true),
Product("p-006", "Ceramic Mug Set", "Four stackable mugs for coffee and tea", "home", "brewcraft", 34.99, true),
Product("p-007", "Air Purifier", "Quiet purifier for bedrooms and small offices", "home", "cleanair", 149.99, false),
Product("p-008", "Desk Lamp", "Adjustable LED lamp with warm and cool modes", "home", "lumina", 44.99, true),
Product("p-009", "Yoga Mat", "Non-slip exercise mat with carrying strap", "fitness", "movefree", 39.99, true),
Product("p-010", "Resistance Bands", "Five-band strength training set", "fitness", "movefree", 24.99, true),
Product("p-011", "Insulated Bottle", "Stainless steel bottle for hot or cold drinks", "fitness", "trailflow", 29.99, true),
Product("p-012", "Foam Roller", "Textured recovery roller for mobility work", "fitness", "movefree", 31.99, false)
)
}
What Should Match Here?
The catalog contains exactly 12 products across electronics, home, plus fitness.
spring.application.name=ecommerce-search
What Should Match Here?
The properties file retains the generated application name.
The literal matcher should handle a word that appears in the catalog. Use earbuds as the first baseline.
- Open a new browser tab.
- Enter http://localhost:8080/api/products/search?q=earbuds in the address bar.
You should see a JSON list containing Wireless Earbuds. The response proves that the endpoint can find exact catalog text.
Test the misspelled query
A shopper can easily drop a letter while typing a product name. This test checks whether the same literal matcher can connect that input to the intended product.
Before you load the page, ask yourself what literal matching will return:
- Replace the browser address with http://localhost:8080/api/products/search?q=earbds.
You will see [] even though Wireless Earbuds exists in the catalog. That empty response is the designed shortfall.
Why Does the Catalog Disappear?
Substring matching only succeeds when the same character sequence appears in a product name or description. No catalog field contains earbds in that order.
A search index can compare close terms through fuzzy matching. This empty result gives you a clear benchmark for the search-backed version.
You have proved that the local endpoint works for exact text. Next, you will create the managed search collection that closes the typo-tolerance gap.
Create the Search Collection
Your Spring Boot API can find exact product terms. The empty result for earbds exposed the limit of substring matching.
Local scanning cannot provide distributed indexing. It also cannot calculate relevance scores or facet counts.
An Amazon OpenSearch Serverless collection gives the catalog a managed search layer. This step creates that collection.
In this step, get ready to:
- Open the Amazon OpenSearch Service collection workflow.
- Create a NextGen Search collection with Express Create.
- Store the collection connection settings in application.properties.
Open the collection workflow
Amazon OpenSearch Service keeps its managed collections under the Serverless area. The collection workflow gathers the search workload settings before AWS creates any resources.
Before you create a billable collection
This creates a usage-based AWS resource. You stay in control because the cleanup section includes its delete path.
NextGen compute can scale to zero after 10 minutes without incoming requests. Indexed storage remains billable until you delete the collection.
- Switch back to the browser you used to test the local API.
- Open a new browser tab.
- Go to the AWS Management Console.
- Sign in with your AWS account.
- Search for Amazon OpenSearch Service in the console search bar.
- Select Amazon OpenSearch Service from the search results.
- Expand Serverless in the left navigation.
- Choose Collections.
You should see the Serverless collections page. This page lists every collection in the selected AWS Region.
- Choose Create collection.
You should see a form for defining the new collection.
Choose the Express Create settings
A Search collection supports full-text search for workloads such as ecommerce product discovery. Express Create supplies the access defaults needed for this project.
- Enter ecommerce-products as the collection name.
- Select Search as the collection type.
- Select Express Create as the creation method.
- Confirm the encryption summary uses an AWS owned key.
- Confirm the network summary allows public network access.
- Confirm the auto-generated data access policy names your current IAM principal.
Provisioning can take several minutes. A quiet status screen is normal while AWS prepares the collection.
- Choose Create collection.
- Wait on the collection details page until the status changes to Active.
That is the slowest part done. Your managed search collection is ready for configuration.
Collection not becoming active?
Confirm that your AWS account can create Amazon OpenSearch Serverless collections. Ask your AWS administrator to review your permissions if the console reports an access problem.
If the status does not progress after several minutes, help me troubleshoot the collection status.
Record the endpoint and region
A per-collection endpoint identifies your collection in its hostname. The AWS Region tells the client where to sign each request.
- Locate the HTTPS endpoint on the collection details page.
- Record the endpoint here: https://your-collection-endpoint.on.aws.
- Record the collection AWS Region here: your-aws-region.
- Switch back to IntelliJ IDEA from earlier.
- Select src/main/resources/application.properties in the Project tool window.
- Add the connection settings by replacing the file contents with this template:
spring.application.name=ecommerce-search
opensearch.endpoint=https://your-collection-endpoint.on.aws
opensearch.region=your-aws-region
What do these settings store?
The opensearch.endpoint property stores the full collection address. The opensearch.region property stores the AWS Region used for request signing.
These values let the application target the correct collection with an AWS-signed request.
You should now see the application name followed by two OpenSearch connection properties.
- Replace https://your-collection-endpoint.on.aws with https://your-collection-endpoint.on.aws.
- Replace your-aws-region with your-aws-region.
The endpoint property should now contain your collection-specific hostname. The region property should match the AWS Region shown in the console.
- Save application.properties.
- Confirm the file tab no longer shows an unsaved-change marker.
Connection settings look wrong?
- Check that the endpoint begins with https://.
- Check that the endpoint ends with .on.aws.
- Check that the region matches the collection details page.
If you are unsure about either value, help me check my OpenSearch configuration.
✔️ Awesome, I've got everything!
Your endpoint and AWS Region are saved in application.properties.
ⓧ I'd like to double check the full code
Use this reference to compare the property order. Your file contains your real endpoint and AWS Region in place of the two placeholders.
spring.application.name=ecommerce-search
opensearch.endpoint=https://your-collection-endpoint.on.aws
opensearch.region=your-aws-region
What should match?
The reference shows the expected property names. Your saved values should come from the active collection details page.
Before the final check, do you expect the collection to be ready or still provisioning?
- Return to the Amazon OpenSearch Service collection details page.
- Refresh the collection details page.
You should see ecommerce-products with status Active. Its HTTPS endpoint should end in .on.aws.
Your managed search collection now has an address that the application can use. Next up, you will connect the Kotlin application and bulk-index the product catalog.
Connect and Bulk-Index the Catalog
Your local API proved that substring scanning breaks on shopper typos. The active Amazon OpenSearch Serverless collection gives the catalog a search engine built for that workload.
Sending one network request for each product would waste round trips. This step uses an explicit mapping plus buffered bulk ingestion to create a typed indexing path.
In this step, get ready to:
- Reload the project with the OpenSearch client dependencies.
- Configure signed requests using your existing AWS identity.
- Create the typed product index through buffered bulk ingestion.
Reload the search dependencies
The OpenSearch Java client provides the mapping plus bulk APIs that the indexer needs. The AWS SDK for Java supplies the HTTP client used by the signed transport.
Why use the direct Java client?
The direct client keeps explicit mappings visible. It also exposes request signing.
Bulk ingestion stays visible in the same layer. You can inspect every search-specific decision in your Kotlin code.
- In build.gradle.kts, replace the entire file with this dependency configuration:
plugins {
kotlin("jvm") version "2.4.20"
kotlin("plugin.spring") version "2.4.20"
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.4.20")
implementation("org.opensearch.client:opensearch-java:3.10.0")
implementation("software.amazon.awssdk:apache-client:2.54.20")
}
What does this configuration add?
- The OpenSearch dependency supplies client version 3.10.0.
- The Apache dependency supplies the AWS SDK HTTP client at version 2.54.20.
- The existing Spring Web MVC dependency continues to serve the local API.
- Save build.gradle.kts.
- Accept the Gradle reload prompt in IntelliJ IDEA.
- Wait for the Gradle reload to finish.
You should see the reload complete without dependency resolution errors.
Gradle reload failed?
- Confirm each dependency version matches the configuration above.
- Check that your internet connection can reach the configured Maven repository.
Help me diagnose the Gradle dependency reload.
✔️ Awesome, I've got everything!
Your Gradle project now includes the two libraries needed for signed OpenSearch requests.
ⓧ I'd like to double check the full code
plugins {
kotlin("jvm") version "2.4.20"
kotlin("plugin.spring") version "2.4.20"
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.4.20")
implementation("org.opensearch.client:opensearch-java:3.10.0")
implementation("software.amazon.awssdk:apache-client:2.54.20")
}
Configure signed OpenSearch requests
Amazon OpenSearch Serverless authenticates data requests with AWS Signature Version 4. The transport signs each request with the local AWS identity you configured earlier.
The per-collection endpoint identifies the target collection through its host. The signing service value aoss identifies Amazon OpenSearch Serverless to the signer.
- In the Project tool window, select com.example.search.
- Create a package named config.
- Create OpenSearchConfig.kt inside the new package.
- Add the property loader plus reusable HTTP client with this code:
package com.example.search.config
import org.opensearch.client.opensearch.OpenSearchClient
import org.opensearch.client.transport.aws.AwsSdk2Transport
import org.opensearch.client.transport.aws.AwsSdk2TransportOptions
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.http.SdkHttpClient
import software.amazon.awssdk.http.apache.ApacheHttpClient
import software.amazon.awssdk.regions.Region
import java.net.URI
import java.util.Properties
@Configuration
class OpenSearchConfig {
private val properties = Properties().apply {
val input = requireNotNull(
OpenSearchConfig::class.java.getResourceAsStream("/application.properties")
) { "application.properties was not found" }
input.use(::load)
}
@Bean(destroyMethod = "close")
fun sdkHttpClient(): SdkHttpClient = ApacheHttpClient.builder().build()
}
What does this configuration do?
- The property loader reads the endpoint plus Region from application.properties.
- The required-resource check stops startup when the configuration file is missing.
- The HTTP client bean gives the signed transport one reusable connection layer.
- Save OpenSearchConfig.kt.
- Run EcommerceSearchApplication from IntelliJ IDEA.
You should see the embedded server start without Kotlin compilation errors.
- Stop the running application.
Configuration class not compiling?
- Confirm the Gradle reload completed before checking the imported classes.
- Check that OpenSearchConfig.kt sits inside src/main/kotlin/com/example/search/config.
Help me fix the OpenSearch configuration imports.
The HTTP client moves requests to the collection. The OpenSearch client adds the collection host plus the signing details needed for authentication.
- In OpenSearchConfig.kt, locate the final closing brace.
- Insert this client bean immediately above that brace:
@Bean
fun openSearchClient(httpClient: SdkHttpClient): OpenSearchClient {
val endpoint = properties.getProperty("opensearch.endpoint")
val region = properties.getProperty("opensearch.region")
val host = requireNotNull(URI.create(endpoint).host) {
"opensearch.endpoint must be a full HTTPS endpoint"
}
val transport = AwsSdk2Transport(
httpClient,
host,
"aoss",
Region.of(region),
AwsSdk2TransportOptions.builder().build()
)
return OpenSearchClient(transport)
}
How are requests signed?
- The URI parser extracts the host from the configured HTTPS endpoint.
- The configured Region determines where the request is signed.
- The transport uses the AWS SDK default credentials provider chain because no custom provider is supplied.
- The service value aoss targets Amazon OpenSearch Serverless.
- Save OpenSearchConfig.kt.
- Run EcommerceSearchApplication from IntelliJ IDEA.
You should see the embedded server start with the OpenSearch client bean loaded.
- Stop the running application.
Application fails during configuration?
- Check the endpoint value already stored in application.properties.
- Confirm the endpoint begins with HTTPS.
- Confirm the configured AWS Region matches the collection Region.
Help me inspect the OpenSearch endpoint configuration.
✔️ Awesome, I've got everything!
Your application can now construct an OpenSearch client from the configured collection endpoint.
ⓧ I'd like to double check the full code
package com.example.search.config
import org.opensearch.client.opensearch.OpenSearchClient
import org.opensearch.client.transport.aws.AwsSdk2Transport
import org.opensearch.client.transport.aws.AwsSdk2TransportOptions
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.http.SdkHttpClient
import software.amazon.awssdk.http.apache.ApacheHttpClient
import software.amazon.awssdk.regions.Region
import java.net.URI
import java.util.Properties
@Configuration
class OpenSearchConfig {
private val properties = Properties().apply {
val input = requireNotNull(
OpenSearchConfig::class.java.getResourceAsStream("/application.properties")
) { "application.properties was not found" }
input.use(::load)
}
@Bean(destroyMethod = "close")
fun sdkHttpClient(): SdkHttpClient = ApacheHttpClient.builder().build()
@Bean
fun openSearchClient(httpClient: SdkHttpClient): OpenSearchClient {
val endpoint = properties.getProperty("opensearch.endpoint")
val region = properties.getProperty("opensearch.region")
val host = requireNotNull(URI.create(endpoint).host) {
"opensearch.endpoint must be a full HTTPS endpoint"
}
val transport = AwsSdk2Transport(
httpClient,
host,
"aoss",
Region.of(region),
AwsSdk2TransportOptions.builder().build()
)
return OpenSearchClient(transport)
}
}
Create and bulk-index products
An explicit mapping fixes the field type before documents arrive. Text fields support analyzed search while keyword fields preserve exact values for filters plus facets.
The BulkIngester buffers product operations before sending them. This reduces network round trips across the catalog.
How are the product fields mapped?
- The name plus description fields use text mappings for full-text search.
- The id, category, plus brand fields use keyword mappings for exact values.
- The price field uses a double mapping.
- The inStock field uses a Boolean mapping.
- In the Project tool window, select com.example.search.
- Create a package named search.
- Create ProductIndexService.kt inside the new package.
- Add the service shell plus index name with this code:
package com.example.search.search
import com.example.search.product.ProductCatalog
import org.opensearch.client.opensearch.OpenSearchClient
import org.opensearch.client.opensearch._helpers.bulk.BulkIngester
import org.opensearch.client.opensearch._types.mapping.BooleanProperty
import org.opensearch.client.opensearch._types.mapping.DoubleNumberProperty
import org.opensearch.client.opensearch._types.mapping.KeywordProperty
import org.opensearch.client.opensearch._types.mapping.Property
import org.opensearch.client.opensearch._types.mapping.TextProperty
import org.opensearch.client.opensearch._types.mapping.TypeMapping
import org.opensearch.client.opensearch.indices.CreateIndexRequest
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@Service
class ProductIndexService(
private val client: OpenSearchClient,
private val catalog: ProductCatalog
) {
companion object {
const val INDEX_NAME = "products-v1"
}
}
What does this service shell provide?
- Spring injects the signed OpenSearch client into the indexing service.
- Spring also injects the existing twelve-product catalog.
- The shared index constant keeps every operation pointed at products-v1.
- Save ProductIndexService.kt.
- Run EcommerceSearchApplication from IntelliJ IDEA.
You should see the embedded server start without unresolved service imports.
- Stop the running application.
Index service not compiling?
- Confirm the file package matches its search directory.
- Confirm ProductCatalog still exists in the product package.
Help me fix the ProductIndexService package or imports.
Index creation happens once. Later application runs reuse the existing mapping instead of attempting to recreate the index.
- In ProductIndexService.kt, locate the companion object block.
- Insert this mapping method immediately above that block:
private fun createIndex() {
val mapping = TypeMapping.Builder()
.properties("id", Property.Builder().keyword(KeywordProperty.Builder().build()).build())
.properties("name", Property.Builder().text(TextProperty.Builder().build()).build())
.properties("description", Property.Builder().text(TextProperty.Builder().build()).build())
.properties("category", Property.Builder().keyword(KeywordProperty.Builder().build()).build())
.properties("brand", Property.Builder().keyword(KeywordProperty.Builder().build()).build())
.properties("price", Property.Builder().double_(DoubleNumberProperty.Builder().build()).build())
.properties("inStock", Property.Builder().boolean_(BooleanProperty.Builder().build()).build())
.build()
val request = CreateIndexRequest.Builder()
.index(INDEX_NAME)
.mappings(mapping)
.build()
client.indices().create(request)
}
What does the mapping method do?
- The mapping assigns a deliberate OpenSearch type to every product field.
- The request applies that mapping when products-v1 is created.
- The explicit types keep exact filters separate from analyzed text search.
- Save ProductIndexService.kt.
- Run EcommerceSearchApplication from IntelliJ IDEA.
You should see the embedded server start with the complete mapping method compiled.
- Stop the running application.
Mapping code has an error?
- Check that each property builder matches the field type described above.
- Confirm the method sits inside ProductIndexService.
Help me compare the product mapping with the Product data class.
The indexing method checks for the index before creating it. It then buffers every catalog product before closing the ingester.
- In ProductIndexService.kt, locate private fun createIndex().
- Insert this indexing method immediately above it:
fun indexCatalog() {
if (!client.indices().exists { it.index(INDEX_NAME) }.value()) {
createIndex()
}
val ingester = BulkIngester.of<Void> { builder ->
builder.client(client)
.maxOperations(100)
.flushInterval(1, TimeUnit.SECONDS)
}
try {
catalog.products.forEach { product ->
ingester.add { operation ->
operation.index { index ->
index.index(INDEX_NAME)
.id(product.id)
.document(product)
}
}
}
} finally {
ingester.close()
}
println("Indexed ${catalog.products.size} products into $INDEX_NAME")
}
How does bulk indexing work?
- The existence check creates the index only when products-v1 is absent.
- The ingester buffers up to one hundred operations per batch.
- The one-second flush interval prevents a small catalog from waiting for a full batch.
- Closing the ingester flushes remaining operations before the success message is printed.
✔️ Awesome, I've got everything!
Your indexing service now creates the explicit index before buffering all twelve products.
ⓧ I'd like to double check the full code
package com.example.search.search
import com.example.search.product.ProductCatalog
import org.opensearch.client.opensearch.OpenSearchClient
import org.opensearch.client.opensearch._helpers.bulk.BulkIngester
import org.opensearch.client.opensearch._types.mapping.BooleanProperty
import org.opensearch.client.opensearch._types.mapping.DoubleNumberProperty
import org.opensearch.client.opensearch._types.mapping.KeywordProperty
import org.opensearch.client.opensearch._types.mapping.Property
import org.opensearch.client.opensearch._types.mapping.TextProperty
import org.opensearch.client.opensearch._types.mapping.TypeMapping
import org.opensearch.client.opensearch.indices.CreateIndexRequest
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@Service
class ProductIndexService(
private val client: OpenSearchClient,
private val catalog: ProductCatalog
) {
fun indexCatalog() {
if (!client.indices().exists { it.index(INDEX_NAME) }.value()) {
createIndex()
}
val ingester = BulkIngester.of<Void> { builder ->
builder.client(client)
.maxOperations(100)
.flushInterval(1, TimeUnit.SECONDS)
}
try {
catalog.products.forEach { product ->
ingester.add { operation ->
operation.index { index ->
index.index(INDEX_NAME)
.id(product.id)
.document(product)
}
}
}
} finally {
ingester.close()
}
println("Indexed ${catalog.products.size} products into $INDEX_NAME")
}
private fun createIndex() {
val mapping = TypeMapping.Builder()
.properties("id", Property.Builder().keyword(KeywordProperty.Builder().build()).build())
.properties("name", Property.Builder().text(TextProperty.Builder().build()).build())
.properties("description", Property.Builder().text(TextProperty.Builder().build()).build())
.properties("category", Property.Builder().keyword(KeywordProperty.Builder().build()).build())
.properties("brand", Property.Builder().keyword(KeywordProperty.Builder().build()).build())
.properties("price", Property.Builder().double_(DoubleNumberProperty.Builder().build()).build())
.properties("inStock", Property.Builder().boolean_(BooleanProperty.Builder().build()).build())
.build()
val request = CreateIndexRequest.Builder()
.index(INDEX_NAME)
.mappings(mapping)
.build()
client.indices().create(request)
}
companion object {
const val INDEX_NAME = "products-v1"
}
}
A startup runner makes indexing repeatable. Every application launch now checks the index before sending the catalog through the bulk path.
- In EcommerceSearchApplication.kt, replace the entire file with this startup configuration:
package com.example.search
import com.example.search.search.ProductIndexService
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
@SpringBootApplication
class EcommerceSearchApplication {
@Bean
fun loadCatalog(indexService: ProductIndexService) = CommandLineRunner {
indexService.indexCatalog()
}
}
fun main(args: Array<String>) {
runApplication<EcommerceSearchApplication>(*args)
}
What does the startup runner do?
- Spring injects ProductIndexService into the runner bean.
- The runner calls indexCatalog() during application startup.
- The server finishes starting after the catalog has passed through the indexing path.
✔️ Awesome, I've got everything!
Your application now invokes the indexer during startup.
ⓧ I'd like to double check the full code
package com.example.search
import com.example.search.search.ProductIndexService
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
@SpringBootApplication
class EcommerceSearchApplication {
@Bean
fun loadCatalog(indexService: ProductIndexService) = CommandLineRunner {
indexService.indexCatalog()
}
}
fun main(args: Array<String>) {
runApplication<EcommerceSearchApplication>(*args)
}
- Save all project files.
Before you run the application, do you expect one request per product or a buffered bulk operation?
A collection returning from scale-to-zero can take 10 to 30 seconds to handle its first request. A quiet Run window during that wake-up period is expected.
- Run EcommerceSearchApplication from IntelliJ IDEA.
You should see Indexed 12 products into products-v1 in the Run window. That confirms the buffered operation finished before startup continued.
Indexing request failed?
- Confirm the endpoint plus Region in application.properties still match the active collection.
- Confirm your local AWS identity matches the principal granted by the collection data access policy.
- If you see a 403 response, confirm the principal has the required Serverless data-plane access.
Help me diagnose the failed Serverless indexing request.
- In the Project tool window, delete src/main/kotlin/com/example/search/product/LocalSearchController.kt.
- Run EcommerceSearchApplication again.
You should see Indexed 12 products into products-v1 again. The temporary substring controller is now gone.
OpenSearch Dashboards can query the managed index directly. The count request verifies the document total independently from the application log.
- Return to the ecommerce-products collection details page in the Amazon OpenSearch Service console.
- Open OpenSearch Dashboards for the collection.
- Select Dev Tools.
Before you run the count request, do you expect the index to contain fewer than twelve documents or the complete catalog?
- Count the indexed documents by running this request:
GET products-v1/_count
What does this request check?
The count API reads the document total from products-v1. It confirms that all buffered product operations reached the managed index.
After the Serverless refresh interval, you should see a count of twelve in the response.
Count is missing or lower than twelve?
- Wait approximately ten seconds for the managed Search index refresh.
- Run the count request again.
- Check the IntelliJ IDEA Run window for an indexing failure above the startup messages.
Help me investigate the OpenSearch document count.
That is the indexing backbone working. Next up, you will query these products with typo-tolerant relevance plus exact category filters plus facets.
Build Ranked, Filtered, Faceted Search
Your Spring Boot application now sends signed requests to Amazon OpenSearch Serverless. Your 12-product catalog is ready for real search traffic.
Shoppers still need typo tolerance. They also need relevance scores. Exact filters must narrow results without distorting those scores. Facets must summarize the available categories.
In this step, get ready to:
- Build a fuzzy multi-field query that ranks matching products.
- Apply an exact category filter and calculate category facet buckets.
- Expose the search service through a local API that returns JSON results.
Build the response model and ranked query
A multi_match query searches several text fields at once. Boosting name^3 gives product-name matches more influence over ranking.
- In IntelliJ IDEA's Project tool window, create ProductSearchService.kt inside src/main/kotlin/com/example/search/search.
- Add the required imports and API response models by pasting this starter content:
package com.example.search.search
import com.example.search.product.Product
import org.opensearch.client.opensearch.OpenSearchClient
import org.opensearch.client.opensearch._types.FieldValue
import org.opensearch.client.opensearch._types.aggregations.Aggregation
import org.opensearch.client.opensearch._types.query_dsl.BoolQuery
import org.opensearch.client.opensearch._types.query_dsl.MultiMatchQuery
import org.opensearch.client.opensearch._types.query_dsl.TermQuery
import org.opensearch.client.opensearch.core.SearchRequest
import org.opensearch.client.opensearch.core.SearchResponse
import org.springframework.stereotype.Service
data class ProductHit(val product: Product, val score: Double?)
data class FacetBucket(val value: String, val count: Long)
data class ProductSearchResponse(
val total: Long,
val products: List<ProductHit>,
val categories: List<FacetBucket>
)
What do these response models represent?
- ProductHit keeps each product beside the relevance score calculated by OpenSearch.
- FacetBucket stores a category value beside the number of matching products.
- ProductSearchResponse combines the total hit count with ranked products and category facets.
- Save ProductSearchService.kt.
- Run EcommerceSearchApplication from IntelliJ IDEA.
You should see the embedded server start on port 8080. The Run window should also show Indexed 12 products into products-v1.
Does the starter file fail to compile?
Confirm that ProductSearchService.kt sits in the com.example.search.search package. Check that every import matches the starter content.
Help me diagnose the Kotlin imports or response models in ProductSearchService.kt.
- Insert the first half of ProductSearchService between the final import and ProductHit by pasting this code:
@Service
class ProductSearchService(private val client: OpenSearchClient) {
fun search(query: String, category: String?): ProductSearchResponse {
val textQuery = MultiMatchQuery.Builder()
.query(query)
.fields("name^3", "description", "brand")
.fuzziness("AUTO")
.build()
.toQuery()
val boolQuery = BoolQuery.Builder().must(textQuery)
if (!category.isNullOrBlank()) {
boolQuery.filter(
TermQuery.Builder()
.field("category")
.value(FieldValue.of(category.lowercase()))
.build()
.toQuery()
)
}
How does ranking stay separate from filtering?
The must clause runs the fuzzy text query across name^3, description, and brand. The AUTO fuzziness setting lets OpenSearch tolerate spelling differences.
The category TermQuery sits in Boolean filter context. It narrows the matching documents without contributing to their relevance scores.
- Keep the insertion point at the end of the category block.
- Wait to rerun the application until the next code chunk completes the class.
Add category facets and map the results
A terms aggregation groups matching documents by an exact field. The resulting buckets can power the category filter menu in a storefront.
- Complete ProductSearchService by pasting this code directly below the category block:
val request = SearchRequest.Builder()
.index(ProductIndexService.INDEX_NAME)
.size(10)
.query(boolQuery.build().toQuery())
.aggregations(
"categories",
Aggregation.Builder().terms { terms ->
terms.field("category").size(10)
}.build()
)
.build()
val response: SearchResponse<Product> = client.search(request, Product::class.java)
val products = response.hits().hits().mapNotNull { hit ->
hit.source()?.let { ProductHit(it, hit.score()) }
}
val categories = response.aggregations()["categories"]
?.sterms()
?.buckets()
?.array()
?.map { bucket -> FacetBucket(bucket.key().toString(), bucket.docCount()) }
?: emptyList()
return ProductSearchResponse(
total = response.hits().total()?.value() ?: products.size.toLong(),
products = products,
categories = categories
)
}
}
What does the completed service do?
SearchRequest targets products-v1. It returns up to 10 products from the combined text query and category filter.
The response mapper preserves each document's score. It also converts category aggregation buckets into FacetBucket values.
- Save ProductSearchService.kt.
- Rerun EcommerceSearchApplication from IntelliJ IDEA.
The Run window should report that the server started on port 8080. You should also see Indexed 12 products into products-v1.
Does the search service stop the application?
Check that the response-mapping chunk appears before the three data classes. Confirm that every opening brace in ProductSearchService has a matching closing brace.
Help me find the Kotlin structure problem in ProductSearchService.kt.
✔️ Awesome, I've got everything!
Your search service now builds ranked queries. It also returns category facet buckets.
ⓧ I'd like to double check the full code
package com.example.search.search
import com.example.search.product.Product
import org.opensearch.client.opensearch.OpenSearchClient
import org.opensearch.client.opensearch._types.FieldValue
import org.opensearch.client.opensearch._types.aggregations.Aggregation
import org.opensearch.client.opensearch._types.query_dsl.BoolQuery
import org.opensearch.client.opensearch._types.query_dsl.MultiMatchQuery
import org.opensearch.client.opensearch._types.query_dsl.TermQuery
import org.opensearch.client.opensearch.core.SearchRequest
import org.opensearch.client.opensearch.core.SearchResponse
import org.springframework.stereotype.Service
@Service
class ProductSearchService(private val client: OpenSearchClient) {
fun search(query: String, category: String?): ProductSearchResponse {
val textQuery = MultiMatchQuery.Builder()
.query(query)
.fields("name^3", "description", "brand")
.fuzziness("AUTO")
.build()
.toQuery()
val boolQuery = BoolQuery.Builder().must(textQuery)
if (!category.isNullOrBlank()) {
boolQuery.filter(
TermQuery.Builder()
.field("category")
.value(FieldValue.of(category.lowercase()))
.build()
.toQuery()
)
}
val request = SearchRequest.Builder()
.index(ProductIndexService.INDEX_NAME)
.size(10)
.query(boolQuery.build().toQuery())
.aggregations(
"categories",
Aggregation.Builder().terms { terms ->
terms.field("category").size(10)
}.build()
)
.build()
val response: SearchResponse<Product> = client.search(request, Product::class.java)
val products = response.hits().hits().mapNotNull { hit ->
hit.source()?.let { ProductHit(it, hit.score()) }
}
val categories = response.aggregations()["categories"]
?.sterms()
?.buckets()
?.array()
?.map { bucket -> FacetBucket(bucket.key().toString(), bucket.docCount()) }
?: emptyList()
return ProductSearchResponse(
total = response.hits().total()?.value() ?: products.size.toLong(),
products = products,
categories = categories
)
}
}
data class ProductHit(val product: Product, val score: Double?)
data class FacetBucket(val value: String, val count: Long)
data class ProductSearchResponse(
val total: Long,
val products: List<ProductHit>,
val categories: List<FacetBucket>
)
Compare this reference with your file from the package declaration through the final response model.
Expose and verify the search endpoint
A REST controller turns the search service into a browser-accessible endpoint. Its optional category parameter controls whether the exact filter is added.
- In IntelliJ IDEA's Project tool window, create ProductSearchController.kt inside src/main/kotlin/com/example/search/search.
- Connect the HTTP endpoint to the search service by pasting this code:
package com.example.search.search
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/products")
class ProductSearchController(private val searchService: ProductSearchService) {
@GetMapping("/search")
fun search(
@RequestParam q: String,
@RequestParam(required = false) category: String?
): ProductSearchResponse = searchService.search(q, category)
}
How does the endpoint reach OpenSearch?
The controller maps requests under /api/products/search to ProductSearchService.search(). Spring supplies the required q value and the optional category value.
The service executes the signed OpenSearch request. The controller returns the typed response as JSON.
- Save ProductSearchController.kt.
- Rerun EcommerceSearchApplication from IntelliJ IDEA.
You should see the server start on port 8080. The catalog should finish indexing before you test the endpoint.
Does the controller fail to start?
Confirm that the controller and service share the com.example.search.search package. Check that the controller passes exactly q and category to the service.
Help me troubleshoot ProductSearchController.kt.
✔️ Awesome, I've got everything!
Your controller is ready to accept product search requests.
ⓧ I'd like to double check the full code
package com.example.search.search
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/products")
class ProductSearchController(private val searchService: ProductSearchService) {
@GetMapping("/search")
fun search(
@RequestParam q: String,
@RequestParam(required = false) category: String?
): ProductSearchResponse = searchService.search(q, category)
}
Compare this reference with your controller from the package declaration through the final closing brace.
- Switch back to the browser from your local-search test.
- Paste http://localhost:8080/api/products/search?q=earbds into the address bar.
- Press Enter.
You should see Wireless Earbuds even though earbds is misspelled. Its result includes an OpenSearch relevance score.
- Compare the total, score, and categories values in the response.
Before you add the category parameter, do you expect the relevance score to remain available after the exact filter runs?
- Paste http://localhost:8080/api/products/search?q=earbds&category=electronics into the address bar.
- Press Enter.
You should see Wireless Earbuds with a score in the filtered response. The categories facet should report electronics results.
That search gap is closed. Your API now handles a typo while keeping exact filtering and facet counts separate from relevance.
Does the browser show an unsuccessful response?
Confirm that EcommerceSearchApplication is still running on port 8080. Check the Run window for a permissions problem or a connection problem involving the configured collection endpoint.
Help me troubleshoot the product search API response.
Secret mission
Add Brand Drill-Down
Your search API can already narrow results by category. Extend the same exact-filter pattern so shoppers can select a brand and see brand facet counts in the response.
Clean Up Your Resources
Clean Up Your Resources
Storage for ecommerce-products remains billable after you stop the local API. Decide whether to keep the project active, pause local work, or delete every resource.
Cost warning
Amazon OpenSearch Serverless has no minimum fee. Compute and storage are billed separately.
Search workers can scale to zero after 10 minutes with no incoming requests. Indexing workers can also scale to zero after 10 minutes with no incoming requests.
Indexed storage remains billable until you delete the collection.
Resources you used:
- The local ecommerce-search Spring Boot project files.
- The Spring Boot API process running in IntelliJ IDEA.
- The ecommerce-products collection containing the products-v1 index with 12 documents.
- The auto-created collection group.
- The auto-created data access policy.
- The auto-created network policy.
- The auto-created encryption policy.
Keep everything running
No cleanup action is needed. This option keeps the search API ready for more development.
- Keep the ecommerce-search files in their current location.
- Leave ecommerce-products in Active status.
- Run the API from IntelliJ IDEA when you need to test it.
Compute can scale to zero after 10 idle minutes. Storage for the indexed catalog remains billable.
Pause - I'll come back to this later
This option stops local processing while preserving the project for later. Storage billing for the collection continues.
- Stop the Spring Boot process using the stop control in the IntelliJ IDEA Run window.
- Keep the ecommerce-search files in their current location.
- Leave ecommerce-products in Active status.
- Avoid sending requests to the collection for at least 10 minutes.
The IntelliJ IDEA Run window should show that the local process has ended. Search and indexing compute can scale to zero after the idle period.
Delete - I don't want to use this again
Deletion is permanent. The indexed catalog and local project files cannot be recovered from these resources afterward.
- Stop the Spring Boot process using the stop control in the IntelliJ IDEA Run window.
- In the Amazon OpenSearch Service console, expand Serverless.
- Choose Collections.
- Select ecommerce-products.
- Choose Delete.
- Confirm the deletion.
The collection deletion removes the products-v1 index. It also removes the 12 indexed documents.
- Open the data access policy list under Serverless.
- Delete the auto-created data access policy that references ecommerce-products if no other collection uses it.
- Open the network policy list under Serverless.
- Delete the auto-created network policy that references ecommerce-products if no other collection uses it.
- Open the encryption policy list under Serverless.
- Delete the auto-created encryption policy that references ecommerce-products if no other collection uses it.
- Open the collection group list under Serverless.
- Delete the auto-created collection group if it no longer contains any collections.
The remaining cleanup removes the project from your Windows computer.
- Use the project path shown in IntelliJ IDEA to locate ecommerce-search in File Explorer.
- Delete the ecommerce-search folder from File Explorer.
- Return to the Collections list under Serverless to verify the cloud cleanup.
You should no longer see ecommerce-products in the collection list. File Explorer should no longer show ecommerce-search at its previous location.
Nice Work!
Excellent work! Your Kotlin ecommerce API built with Spring Boot now turns misspelled product queries into ranked results from Amazon OpenSearch Serverless.
What you learned:
- Signed local API requests with AWS Signature Version 4 using your configured AWS identity.
- Created an explicitly mapped search index for text fields. Loaded 12 catalog products through buffered bulk ingestion.
- Built typo-tolerant ranked search for misspelled queries. Applied exact category filters. Returned category facet counts for storefront navigation.
- Secret Mission: Extended the API with exact brand filtering. Returned brand facet counts alongside category facets.
Ready to quiz yourself?