diff --git a/compatibility-tests/sdk-test-java/pom.xml b/compatibility-tests/sdk-test-java/pom.xml
index 7c111e2bd5..f8bb3bf3c8 100644
--- a/compatibility-tests/sdk-test-java/pom.xml
+++ b/compatibility-tests/sdk-test-java/pom.xml
@@ -302,6 +302,11 @@
software.amazon.awssdk
marketplacedeployment
+
+
+ software.amazon.awssdk
+ marketplacediscovery
+
software.amazon.awssdk
diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
index 7d0a816ec7..9dfcf4f181 100644
--- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
+++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
@@ -45,6 +45,7 @@
import software.amazon.awssdk.services.marketplaceentitlement.MarketplaceEntitlementClient;
import software.amazon.awssdk.services.verifiedpermissions.VerifiedPermissionsClient;
import software.amazon.awssdk.services.marketplacedeployment.MarketplaceDeploymentClient;
+import software.amazon.awssdk.services.marketplacediscovery.MarketplaceDiscoveryClient;
import software.amazon.awssdk.services.inspector2.Inspector2Client;
import software.amazon.awssdk.services.securityhub.SecurityHubClient;
import software.amazon.awssdk.services.detective.DetectiveClient;
@@ -464,6 +465,14 @@ public static MarketplaceDeploymentClient marketplaceDeploymentClient() {
.build();
}
+ public static MarketplaceDiscoveryClient marketplaceDiscoveryClient() {
+ return MarketplaceDiscoveryClient.builder()
+ .endpointOverride(ENDPOINT)
+ .region(REGION)
+ .credentialsProvider(CREDENTIALS)
+ .build();
+ }
+
public static DetectiveClient detectiveClient(String accountId) {
return DetectiveClient.builder()
.endpointOverride(ENDPOINT)
diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/MarketplaceDiscoveryTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/MarketplaceDiscoveryTest.java
new file mode 100644
index 0000000000..8abd990e5f
--- /dev/null
+++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/MarketplaceDiscoveryTest.java
@@ -0,0 +1,19 @@
+package com.floci.test;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.marketplacediscovery.MarketplaceDiscoveryClient;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MarketplaceDiscoveryTest {
+
+ @Test
+ void usesAwsSdkWireContract() {
+ try (MarketplaceDiscoveryClient client = TestFixtures.marketplaceDiscoveryClient()) {
+ var response = client.searchListings(r -> r.searchText("sdk-compat"));
+ assertTrue(response.totalResults() >= 0);
+ assertNotNull(response.listingSummaries());
+ }
+ }
+}
diff --git a/docs/services/marketplace.md b/docs/services/marketplace.md
index f1a6b1d94d..57cc2ea45e 100644
--- a/docs/services/marketplace.md
+++ b/docs/services/marketplace.md
@@ -49,6 +49,15 @@ Floci emulates AWS Marketplace APIs under the shared `aws-marketplace` SigV4 sig
| `UpdatePurchaseOrders` | Updates purchase orders for an agreement |
| `GetEntitlements` | - |
| `PutDeploymentParameter` | Creates or updates an AWS Marketplace deployment parameter |
+| `GetListing` | Returns full buyer-facing listing details |
+| `GetOffer` | Returns an offer and its associated product information |
+| `GetOfferSet` | Returns an offer set and its associated offers and products |
+| `GetOfferTerms` | Returns the terms attached to an offer with pagination |
+| `GetProduct` | Returns Marketplace product details |
+| `ListFulfillmentOptions` | Lists fulfillment options available for a product |
+| `ListPurchaseOptions` | Lists buyer purchase options with filtering and pagination |
+| `SearchFacets` | Returns paginated facet values for matching listings |
+| `SearchListings` | Searches listings with filtering, sorting, and pagination |
## Marketplace Catalog
@@ -73,6 +82,10 @@ AWS exposes this service as read-only: `GetEntitlements` is the only public oper
Deployment parameters and idempotency records are persisted through `StorageFactory` and isolated by AWS account.
+## Marketplace Discovery
+
+Marketplace Discovery reads the shared Marketplace Catalog entity backend and exposes listing, offer, purchase-option, facet, filtering, sorting, and pagination behavior across the supported Discovery regions.
+
## Configuration
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java
index 1ba26c6e91..63105e6d6d 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java
@@ -53,6 +53,7 @@
import io.github.hectorvent.floci.services.efs.EfsController;
import io.github.hectorvent.floci.services.marketplace.MarketplaceCatalogController;
import io.github.hectorvent.floci.services.marketplace.MarketplaceDeploymentController;
+import io.github.hectorvent.floci.services.marketplace.MarketplaceDiscoveryController;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -658,7 +659,7 @@ public ResolvedServiceCatalog(EmulatorConfig config) {
"marketplace", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON,
protocols(ServiceProtocol.REST_JSON, ServiceProtocol.JSON, ServiceProtocol.CBOR),
Set.of("AWSMPCommerceService_v20200301.", "AWSMPEntitlementService."), Set.of("aws-marketplace"), Set.of("AWS Marketplace Entitlement Service"),
- Set.of(MarketplaceCatalogController.class, MarketplaceDeploymentController.class))
+ Set.of(MarketplaceCatalogController.class, MarketplaceDeploymentController.class, MarketplaceDiscoveryController.class))
));
}
diff --git a/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryController.java b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryController.java
new file mode 100644
index 0000000000..ea81d578fa
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryController.java
@@ -0,0 +1,80 @@
+package io.github.hectorvent.floci.services.marketplace;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+import io.github.hectorvent.floci.core.common.AwsException;
+import io.github.hectorvent.floci.core.common.RequestContext;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+@Path("/2026-02-05")
+@Consumes(MediaType.APPLICATION_JSON)
+@Produces(MediaType.APPLICATION_JSON)
+public class MarketplaceDiscoveryController {
+ private final MarketplaceDiscoveryService service;
+ private final ObjectReader strictReader;
+ private final RequestContext context;
+
+ @Inject
+ public MarketplaceDiscoveryController(MarketplaceDiscoveryService service, ObjectMapper mapper, RequestContext context) {
+ this.service = service;
+ this.strictReader = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ this.context = context;
+ }
+ @POST
+ @Path("/getListing")
+ public Response getListing(String body) { return ok(service.getListing(parse(body), region())); }
+
+ @POST
+ @Path("/getOffer")
+ public Response getOffer(String body) { return ok(service.getOffer(parse(body), region())); }
+
+ @POST
+ @Path("/getOfferSet")
+ public Response getOfferSet(String body) { return ok(service.getOfferSet(parse(body), region())); }
+
+ @POST
+ @Path("/getOfferTerms")
+ public Response getOfferTerms(String body) { return ok(service.getOfferTerms(parse(body), region())); }
+
+ @POST
+ @Path("/getProduct")
+ public Response getProduct(String body) { return ok(service.getProduct(parse(body), region())); }
+
+ @POST
+ @Path("/listFulfillmentOptions")
+ public Response listFulfillmentOptions(String body) { return ok(service.listFulfillmentOptions(parse(body), region())); }
+
+ @POST
+ @Path("/listPurchaseOptions")
+ public Response listPurchaseOptions(String body) { return ok(service.listPurchaseOptions(parse(body), region())); }
+
+ @POST
+ @Path("/searchFacets")
+ public Response searchFacets(String body) { return ok(service.searchFacets(parse(body), region())); }
+
+ @POST
+ @Path("/searchListings")
+ public Response searchListings(String body) { return ok(service.searchListings(parse(body), region())); }
+
+ private JsonNode parse(String body) {
+ try {
+ JsonNode node = strictReader.readTree(body == null || body.isBlank() ? "{}" : body);
+ if (node == null || !node.isObject()) {
+ throw validation("Request body must be a JSON object.");
+ }
+ return node;
+ } catch (AwsException e) { throw e; }
+ catch (Exception e) { throw validation("Request body is not valid JSON."); }
+ }
+ private String region() { return context.getRegion() == null ? "us-east-1" : context.getRegion(); }
+ private Response ok(JsonNode node) { return Response.ok(node).build(); }
+ private static AwsException validation(String message) { return new AwsException("ValidationException", message, 400); }
+}
diff --git a/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryService.java b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryService.java
new file mode 100644
index 0000000000..68a1e1c51f
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryService.java
@@ -0,0 +1,420 @@
+package io.github.hectorvent.floci.services.marketplace;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.github.hectorvent.floci.core.common.AwsException;
+import io.github.hectorvent.floci.core.common.Resettable;
+import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
+import io.github.hectorvent.floci.core.storage.StorageFactory;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+@ApplicationScoped
+public class MarketplaceDiscoveryService implements Resettable {
+ private static final Set REGIONS = Set.of("us-east-1", "us-west-2", "eu-west-1");
+ private static final Set FACET_TYPES = Set.of("AVERAGE_CUSTOMER_RATING", "CATEGORY", "PUBLISHER", "FULFILLMENT_OPTION_TYPE", "PRICING_MODEL", "PRICING_UNIT", "DEPLOYED_ON_AWS", "NUMBER_OF_PRODUCTS");
+ private static final Set SEARCH_FILTER_TYPES = Set.of("MIN_AVERAGE_CUSTOMER_RATING", "MAX_AVERAGE_CUSTOMER_RATING", "CATEGORY", "PUBLISHER", "FULFILLMENT_OPTION_TYPE", "PRICING_MODEL", "PRICING_UNIT", "DEPLOYED_ON_AWS", "NUMBER_OF_PRODUCTS");
+ private final ObjectMapper mapper;
+ private final AccountAwareStorageBackend entities;
+
+ @Inject
+ public MarketplaceDiscoveryService(ObjectMapper mapper, StorageFactory factory) {
+ this(mapper, factory.create("marketplace", "marketplace-entities.json",
+ new TypeReference