From bb6567a1abaf8e903658099f2f6e655a94d810f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Fri, 11 Sep 2026 00:56:43 -0400 Subject: [PATCH] feat(marketplace-discovery): emulate AWS Marketplace Discovery API --- compatibility-tests/sdk-test-java/pom.xml | 5 + .../java/com/floci/test/TestFixtures.java | 9 + .../floci/test/MarketplaceDiscoveryTest.java | 19 + docs/services/marketplace.md | 13 + .../core/common/ResolvedServiceCatalog.java | 3 +- .../MarketplaceDiscoveryController.java | 80 ++++ .../MarketplaceDiscoveryService.java | 420 ++++++++++++++++++ .../model/MarketplaceListingSummary.java | 9 + .../MarketplaceDiscoveryIntegrationTest.java | 95 ++++ .../MarketplaceDiscoveryServiceTest.java | 125 ++++++ tools/docs/services.yaml | 1 + 11 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 compatibility-tests/sdk-test-java/src/test/java/com/floci/test/MarketplaceDiscoveryTest.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryController.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryService.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/marketplace/model/MarketplaceListingSummary.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryIntegrationTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryServiceTest.java 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>() {})); + } + + MarketplaceDiscoveryService(ObjectMapper mapper, AccountAwareStorageBackend entities) { + this.mapper = mapper; + this.entities = entities; + } + + public ObjectNode getProduct(JsonNode request, String region) { + validateRegion(region); String id = id(request, "productId"); JsonNode entity = find(region, id, "Product"); + return product(entity); + } + + public ObjectNode getListing(JsonNode request, String region) { + validateRegion(region); String id = id(request, "listingId"); JsonNode entity = find(region, id, "Product"); + ObjectNode details = details(entity); ObjectNode out = mapper.createObjectNode(); + out.put("listingId", id); out.put("listingName", title(entity)); out.put("catalog", "AWSMarketplace"); + out.put("shortDescription", string(details, "ShortDescription", "Local AWS Marketplace listing")); + out.put("longDescription", string(details, "LongDescription", out.path("shortDescription").asText())); + out.put("logoThumbnailUrl", string(details, "LogoUrl", "https://example.invalid/marketplace/" + id + ".png")); + out.set("publisher", seller(details)); out.set("associatedEntities", array(productInfo(entity))); + out.set("badges", mapper.createArrayNode()); out.set("categories", categories(details)); out.set("fulfillmentOptionSummaries", fulfillmentSummaries(details)); + out.set("highlights", stringArray(details.get("Highlights"))); out.set("pricingModels", pricingModels(details)); out.set("pricingUnits", pricingUnits(details)); + out.set("promotionalMedia", mapper.createArrayNode()); out.set("resources", mapper.createArrayNode()); out.set("sellerEngagements", mapper.createArrayNode()); out.set("useCases", mapper.createArrayNode()); + out.set("reviewSummary", mapper.createObjectNode().set("reviewSourceSummaries", mapper.createArrayNode())); return out; + } + + public ObjectNode getOffer(JsonNode request, String region) { + validateRegion(region); String id=id(request,"offerId"); JsonNode e=find(region,id,"Offer"); ObjectNode d=details(e); ObjectNode out=mapper.createObjectNode(); + out.put("offerId",id); out.put("catalog","AWSMarketplace"); out.put("offerName",title(e)); out.put("agreementProposalId",string(d,"AgreementProposalId",id)); + out.set("sellerOfRecord",seller(d)); out.set("associatedEntities", offerAssociations(d, region)); out.set("pricingModel", pricingModel(d)); out.set("badges",mapper.createArrayNode()); return out; + } + + public ObjectNode getOfferSet(JsonNode request, String region) { + validateRegion(region); + String id = id(request, "offerSetId"); + JsonNode entity = find(region, id, "OfferSet"); + ObjectNode details = details(entity); + ObjectNode out = mapper.createObjectNode(); + out.put("offerSetId", id); + out.put("catalog", "AWSMarketplace"); + out.put("offerSetName", title(entity)); + out.set("sellerOfRecord", seller(details)); + out.set("badges", mapper.createArrayNode()); + out.set("associatedEntities", offerSetAssociations(details, region)); + return out; + } + + public ObjectNode getOfferTerms(JsonNode request,String region){ + validateRegion(region); + JsonNode offer = find(region, id(request,"offerId"), "Offer"); + ObjectNode details = details(offer); + JsonNode terms = details.has("OfferTerms") ? details.get("OfferTerms") : details.get("Terms"); + return page("offerTerms", nodes(terms), request, 25, 25); + } + public ObjectNode listFulfillmentOptions(JsonNode request,String region){ + validateRegion(region); JsonNode e=find(region,id(request,"productId"),"Product"); ObjectNode d=details(e); + List options = new ArrayList<>(); + if ("SAAS".equals(d.path("FulfillmentType").asText("SAAS"))) { + ObjectNode saas = mapper.createObjectNode(); + ObjectNode value = saas.putObject("saasFulfillmentOption"); + value.put("fulfillmentOptionId", "fo-" + e.path("EntityId").asText()); + value.put("fulfillmentOptionType", "SAAS"); + value.put("fulfillmentOptionDisplayName", "SaaS"); + if (d.hasNonNull("FulfillmentUrl")) { + value.put("fulfillmentUrl", d.path("FulfillmentUrl").asText()); + } + if (d.hasNonNull("UsageInstructions")) { + value.put("usageInstructions", d.path("UsageInstructions").asText()); + } + options.add(saas); + } + return page("fulfillmentOptions", options, request, 50, 50); + } + + public ObjectNode listPurchaseOptions(JsonNode request,String region){ + validateRegion(region); + JsonNode filters = request.get("filters"); + if (filters != null && (!filters.isArray() || filters.isEmpty() || filters.size() > 10)) { + throw validation("filters must contain between 1 and 10 values."); + } + if (!hasFilter(filters, "PRODUCT_ID") && !hasFilter(filters, "VISIBILITY_SCOPE")) { + throw validation("At least one PRODUCT_ID or VISIBILITY_SCOPE filter is required."); + } + List out=new ArrayList<>(); for(JsonNode e:entities.scan(key -> true)){String type=e.path("EntityType").asText();if(type.contains("Offer")&&!type.contains("OfferSet")&&matchesPurchase(e,filters,region)){ + out.add(purchaseSummary(e,region)); + }else if(type.contains("OfferSet")&&matchesPurchase(e,filters,region)){ + out.add(purchaseSummary(e,region)); + }} + return page("purchaseOptions",out,request,50,100); + } + + public ObjectNode searchListings(JsonNode request,String region){ + validateRegion(region); + validateSearchRequest(request); + List list = matchingListings(request, region); + List publicListings = new ArrayList<>(); + for (JsonNode listing : list) { + ObjectNode publicListing = (ObjectNode) listing.deepCopy(); + publicListing.remove(List.of("_deployedOnAws", "_numberOfProducts")); + publicListings.add(publicListing); + } + ObjectNode response = page("listingSummaries", publicListings, request, 20, 100); + response.put("totalResults", list.size()); + return response; + } + + public ObjectNode searchFacets(JsonNode request,String region){ + validateRegion(region); validateSearchRequest(request); List found=matchingListings(request, region); + Set requested=new LinkedHashSet<>();JsonNode types=request.get("facetTypes"); + if(types!=null){if(!types.isArray()||types.size()>30){ + throw validation("facetTypes must contain at most 30 values."); + }for(JsonNode n:types){String type=n.asText();if(!FACET_TYPES.contains(type)){ + throw validation("Unsupported facet type: "+type); + }requested.add(type);}} + if(requested.isEmpty()){ + requested.addAll(FACET_TYPES); + } + List allFacets = new ArrayList<>(); + for (String type : requested) { + for (JsonNode value : facets(type, found)) { + allFacets.add(new FacetValue(type, value)); + } + } + int offset = token(request.path("nextToken").asText(null)); + if (offset > allFacets.size()) { + throw validation("nextToken is invalid."); + } + int end = Math.min(allFacets.size(), offset + 100); + ObjectNode map = mapper.createObjectNode(); + for (int i = offset; i < end; i++) { + FacetValue facet = allFacets.get(i); + ArrayNode values = map.withArray(facet.type()); + values.add(facet.value().deepCopy()); + } + ObjectNode out = mapper.createObjectNode(); + out.put("totalResults", found.size()); + out.set("listingFacets", map); + if (end < allFacets.size()) { + out.put("nextToken", Integer.toString(end)); + } + return out; + } + + private List matchingListings(JsonNode request, String region) { + String text=request.path("searchText").asText("").toLowerCase(Locale.ROOT);List list=new ArrayList<>(); + for(JsonNode e:entities.scan(key -> true)){if(!isProduct(e)){ + continue; + }ObjectNode summary=listingSummary(e); + ObjectNode entityDetails = details(e); + if(!text.isBlank()&&!summary.toString().toLowerCase(Locale.ROOT).contains(text)){ + continue; + }if(!matchesSearch(summary, entityDetails, request.get("filters"))){ + continue; + } + summary.put("_deployedOnAws", entityDetails.path("DeployedOnAws").asText("DEPLOYED")); + summary.put("_numberOfProducts", entityDetails.path("NumberOfProducts").asInt(1)); + list.add(summary);} + String sortBy = request.path("sortBy").asText("RELEVANCE"); + Comparator comparator = "AVERAGE_CUSTOMER_RATING".equals(sortBy) + ? Comparator.comparingDouble(MarketplaceDiscoveryService::averageRating) + : Comparator.comparing(n -> n.path("listingName").asText()); + list.sort(comparator); + String sortOrder = request.path("sortOrder").asText( + "AVERAGE_CUSTOMER_RATING".equals(sortBy) ? "DESCENDING" : "ASCENDING"); + if ("DESCENDING".equals(sortOrder)) { + java.util.Collections.reverse(list); + } + return list; + } + + private ObjectNode product(JsonNode entity) { + ObjectNode details = details(entity); + ObjectNode out = mapper.createObjectNode(); + out.put("productId", entity.path("EntityId").asText()); + out.put("catalog", "AWSMarketplace"); + out.put("productName", title(entity)); + out.set("manufacturer", seller(details)); + out.put("deployedOnAws", details.path("DeployedOnAws").asText("DEPLOYED")); + out.put("shortDescription", string(details, "ShortDescription", "Local AWS Marketplace product")); + out.put("longDescription", string(details, "LongDescription", out.path("shortDescription").asText())); + out.put("logoThumbnailUrl", string(details, "LogoUrl", + "https://example.invalid/marketplace/" + entity.path("EntityId").asText() + ".png")); + out.set("fulfillmentOptionSummaries", fulfillmentSummaries(details)); + out.set("categories", categories(details)); + out.set("highlights", stringArray(details.get("Highlights"))); + out.set("promotionalMedia", mapper.createArrayNode()); + out.set("resources", mapper.createArrayNode()); + out.set("sellerEngagements", mapper.createArrayNode()); + return out; + } + private ObjectNode listingSummary(JsonNode e){ObjectNode p=product(e);ObjectNode d=details(e);ObjectNode out=mapper.createObjectNode();out.put("listingId",p.path("productId").asText());out.put("listingName",p.path("productName").asText());out.set("publisher",p.path("manufacturer").deepCopy());out.set("fulfillmentOptionSummaries",p.path("fulfillmentOptionSummaries").deepCopy());out.put("catalog","AWSMarketplace");out.put("shortDescription",p.path("shortDescription").asText());out.put("logoThumbnailUrl",p.path("logoThumbnailUrl").asText());out.set("categories",p.path("categories").deepCopy());out.set("badges",mapper.createArrayNode());ObjectNode review=mapper.createObjectNode();ArrayNode sources=review.putArray("reviewSourceSummaries");if(d.hasNonNull("AverageCustomerRating")){sources.addObject().put("sourceName","AWS Marketplace").put("sourceId","AWS_MARKETPLACE").put("averageRating",d.path("AverageCustomerRating").asText()).put("totalReviews",d.path("TotalReviews").asInt(0));}out.set("reviewSummary",review);out.set("pricingModels",pricingModels(d));out.set("pricingUnits",pricingUnits(d));out.set("associatedEntities",array(productInfo(e)));return out;} + private ObjectNode purchaseSummary(JsonNode e,String region){ObjectNode d=details(e);boolean set=e.path("EntityType").asText().contains("OfferSet");ObjectNode out=mapper.createObjectNode().put("purchaseOptionId",e.path("EntityId").asText()).put("catalog","AWSMarketplace").put("purchaseOptionType",set?"OFFERSET":"OFFER").put("purchaseOptionName",title(e));out.set("sellerOfRecord",seller(d));out.set("badges",mapper.createArrayNode());ArrayNode a=mapper.createArrayNode();if(!set){String productId=d.path("ProductId").asText(null);if(productId!=null){ObjectNode rel=mapper.createObjectNode();rel.set("product",productInfo(find(region,productId,"Product")));rel.set("offer",offerInfo(e));a.add(rel);}}out.set("associatedEntities",a);return out;} + private ArrayNode offerAssociations(JsonNode d,String region){ArrayNode a=mapper.createArrayNode();String pid=d.path("ProductId").asText(null);if(pid!=null){ObjectNode r=mapper.createObjectNode();r.set("product",productInfo(find(region,pid,"Product")));a.add(r);}return a;} + private ArrayNode offerSetAssociations(JsonNode d,String region){ArrayNode out=mapper.createArrayNode();JsonNode pairs=d.get("Offers");if(pairs!=null&&pairs.isArray()){ + for(JsonNode pair:pairs){String pid=pair.path("ProductId").asText(null),oid=pair.path("OfferId").asText(null);if(pid!=null&&oid!=null){ObjectNode r=mapper.createObjectNode();r.set("product",productInfo(find(region,pid,"Product")));r.set("offer",offerInfo(find(region,oid,"Offer")));out.add(r);}} + }return out;} + private ObjectNode productInfo(JsonNode e){ObjectNode d=details(e);ObjectNode o=mapper.createObjectNode().put("productId",e.path("EntityId").asText()).put("productName",title(e));o.set("manufacturer",seller(d));return o;} + private ObjectNode offerInfo(JsonNode e){ObjectNode d=details(e);ObjectNode o=mapper.createObjectNode().put("offerId",e.path("EntityId").asText()).put("offerName",title(e));o.set("sellerOfRecord",seller(d));return o;} + private ObjectNode seller(JsonNode d){String name=string(d,"SellerName",string(d,"Manufacturer","Local seller"));return mapper.createObjectNode().put("sellerProfileId",string(d,"SellerProfileId","seller-local")).put("displayName",name);} + private ArrayNode fulfillmentSummaries(JsonNode d){ArrayNode a=mapper.createArrayNode();JsonNode existing=d.get("FulfillmentOptionSummaries");if(existing!=null&&existing.isArray()){ + return (ArrayNode)existing.deepCopy(); + }a.addObject().put("fulfillmentOptionType",d.path("FulfillmentType").asText("SAAS")).put("displayName",d.path("FulfillmentType").asText("SaaS"));return a;} + private ArrayNode categories(JsonNode d){ArrayNode a=mapper.createArrayNode();JsonNode n=d.get("Categories");if(n!=null&&n.isArray()){ + for(JsonNode v:n){if(v.isObject()){ + a.add(v.deepCopy()); +}else { + a.addObject().put("categoryId",v.asText()).put("displayName",v.asText()); +}} + }return a;} + private ArrayNode pricingModels(JsonNode d){JsonNode n=d.get("PricingModels");if(n!=null&&n.isArray()){ + return (ArrayNode)n.deepCopy(); + }ArrayNode a=mapper.createArrayNode();a.addObject().put("pricingModelType",d.path("PricingModel").asText("FREE")).put("displayName",d.path("PricingModel").asText("Free"));return a;} + private ArrayNode pricingUnits(JsonNode d){JsonNode n=d.get("PricingUnits");return n!=null&&n.isArray()?(ArrayNode)n.deepCopy():mapper.createArrayNode();} + private ObjectNode pricingModel(JsonNode d){JsonNode n=d.get("PricingModel");if(n!=null&&n.isObject()){ + return (ObjectNode)n.deepCopy(); + }String value=n!=null&&n.isTextual()?n.asText():"FREE";return mapper.createObjectNode().put("pricingModelType",value).put("displayName",value);} + private ArrayNode facets(String type,List listings){Map counts=new LinkedHashMap<>();for(JsonNode l:listings){if("PUBLISHER".equals(type)){ + counts.merge(l.path("publisher").path("displayName").asText("Local seller"),1,Integer::sum); + }else if("CATEGORY".equals(type)){ + l.path("categories").forEach(v->counts.merge(v.path("categoryId").asText(),1,Integer::sum)); + }else if("FULFILLMENT_OPTION_TYPE".equals(type)){ + l.path("fulfillmentOptionSummaries").forEach(v->counts.merge(v.path("fulfillmentOptionType").asText(),1,Integer::sum)); + }else if("PRICING_MODEL".equals(type)){ + l.path("pricingModels").forEach(v->counts.merge(v.path("pricingModelType").asText(),1,Integer::sum)); + }else if("PRICING_UNIT".equals(type)){ + l.path("pricingUnits").forEach(v->counts.merge(v.path("pricingUnitType").asText(),1,Integer::sum)); + }else if("DEPLOYED_ON_AWS".equals(type)){ + counts.merge(l.path("_deployedOnAws").asText("DEPLOYED"),1,Integer::sum); + }else if("NUMBER_OF_PRODUCTS".equals(type)){ + counts.merge(l.path("_numberOfProducts").asText("1"),1,Integer::sum); + }}ArrayNode a=mapper.createArrayNode();counts.forEach((v,c)->a.addObject().put("value",v).put("displayName",v).put("count",c));return a;} + private void validateSearchRequest(JsonNode request) { + String searchText = request.path("searchText").asText(null); + if (searchText != null && (searchText.isBlank() || searchText.length() > 512)) { + throw validation("searchText must contain between 1 and 512 characters."); + } + String sortBy = request.path("sortBy").asText(null); if (sortBy != null && !Set.of("RELEVANCE", "AVERAGE_CUSTOMER_RATING").contains(sortBy)) { + throw validation("Unsupported sortBy value."); + } + String sortOrder = request.path("sortOrder").asText(null); if (sortOrder != null && !Set.of("ASCENDING", "DESCENDING").contains(sortOrder)) { + throw validation("Unsupported sortOrder value."); + } + JsonNode filters=request.get("filters"); if(filters!=null){if(!filters.isArray()||filters.isEmpty()||filters.size()>30){ + throw validation("filters must contain between 1 and 30 values."); + }for(JsonNode f:filters){String type=f.path("filterType").asText();if(!SEARCH_FILTER_TYPES.contains(type)){ + throw validation("Unsupported search filter: "+type); + } + JsonNode values = f.get("filterValues"); + if (values == null || !values.isArray() || values.isEmpty() || values.size() > 30) { + throw validation("filterValues must contain between 1 and 30 values."); + } + for (JsonNode value : values) { + if (!value.isTextual() || value.asText().length() < 1 || value.asText().length() > 128 + || !value.asText().matches("[\\w\\-.]+")) { + throw validation("filterValues entries must match [\\w\\-.]+ and be 1 to 128 characters."); + } + } + }} + } + + private boolean matchesSearch(JsonNode s, JsonNode details, JsonNode filters){if(filters==null||!filters.isArray()){ + return true; + }for(JsonNode f:filters){String t=f.path("filterType").asText();JsonNode vals=f.path("filterValues");boolean ok=false;if("MIN_AVERAGE_CUSTOMER_RATING".equals(t)||"MAX_AVERAGE_CUSTOMER_RATING".equals(t)){if(vals.size()!=1){ + throw validation("Rating filters accept exactly one value."); + }double wanted=rating(vals.get(0));double actual=averageRating(s);ok="MIN_AVERAGE_CUSTOMER_RATING".equals(t)?actual>=wanted:actual<=wanted;}else{for(JsonNode v:vals){String w=v.asText();if("CATEGORY".equals(t)){ + for (JsonNode c : s.path("categories")) { + ok |= w.equals(c.path("categoryId").asText()); + } + }else if("PUBLISHER".equals(t)){ + ok|=w.equals(s.path("publisher").path("displayName").asText()); + }else if("PRICING_MODEL".equals(t)){ + for (JsonNode p : s.path("pricingModels")) { + ok |= w.equals(p.path("pricingModelType").asText()); + } + }else if("PRICING_UNIT".equals(t)){ + for (JsonNode p : s.path("pricingUnits")) { + ok |= w.equals(p.path("pricingUnitType").asText()); + } + }else if("FULFILLMENT_OPTION_TYPE".equals(t)){ + for (JsonNode p : s.path("fulfillmentOptionSummaries")) { + ok |= w.equals(p.path("fulfillmentOptionType").asText()); + } + }else if("DEPLOYED_ON_AWS".equals(t)){ + ok |= w.equals(details.path("DeployedOnAws").asText("DEPLOYED")); + }else if("NUMBER_OF_PRODUCTS".equals(t)){ + ok |= w.equals(details.path("NumberOfProducts").asText("1")); + }}}if(!ok){ + return false; + }}return true;} + private static double rating(JsonNode value){try{double rating=Double.parseDouble(value.asText());if(rating<0.0d||rating>5.0d){ + throw new NumberFormatException(); + }return rating;}catch(NumberFormatException e){throw validation("Customer rating filters must be between 0.0 and 5.0.");}} + private static double averageRating(JsonNode listing){JsonNode sources=listing.path("reviewSummary").path("reviewSourceSummaries");if(!sources.isArray()||sources.isEmpty()){ + return 0.0d; + }try{return Double.parseDouble(sources.get(0).path("averageRating").asText("0"));}catch(NumberFormatException e){return 0.0d;}} + private boolean matchesPurchase(JsonNode entity, JsonNode filters, String region) { + if (filters == null || !filters.isArray()) { + return true; + } + ObjectNode details = details(entity); + boolean offerSet = entity.path("EntityType").asText().contains("OfferSet"); + for (JsonNode filter : filters) { + String type = filter.path("filterType").asText(); + JsonNode values = filter.path("filterValues"); + if (values == null || !values.isArray() || values.isEmpty() || values.size() > 10) { + throw validation("Purchase option filterValues must contain between 1 and 10 values."); + } + for (JsonNode value : values) { + if (!value.isTextual() || value.asText().length() < 1 || value.asText().length() > 255 + || !value.asText().matches("[\\w\\-]+")) { + throw validation( + "Purchase option filterValues entries must match [\\w\\-]+ and be 1 to 255 characters."); + } + } + String actual = switch (type) { + case "PRODUCT_ID" -> details.path("ProductId").asText(); + case "SELLER_OF_RECORD_PROFILE_ID" -> seller(details).path("sellerProfileId").asText(); + case "PURCHASE_OPTION_TYPE" -> offerSet ? "OFFERSET" : "OFFER"; + case "VISIBILITY_SCOPE" -> details.path("VisibilityScope").asText("PUBLIC"); + case "AVAILABILITY_STATUS" -> details.path("AvailabilityStatus").asText("AVAILABLE"); + default -> throw validation("Unsupported purchase option filter: " + type); + }; + boolean matches = false; + for (JsonNode value : values) { + matches |= value.asText().equals(actual); + } + if (!matches) { + return false; + } + } + return true; + } + private static boolean hasFilter(JsonNode filters,String name){if(filters!=null&&filters.isArray()){ + for (JsonNode f : filters) { + if (name.equals(f.path("filterType").asText())) { + return true; + } + } + }return false;} + private JsonNode find(String region,String id,String kind){for(JsonNode e:entities.scan(key -> true)){String t=e.path("EntityType").asText();boolean match=switch(kind){case "Product"->isProduct(e);case "OfferSet"->t.contains("OfferSet");case "Offer"->t.contains("Offer")&&!t.contains("OfferSet");default->false;};if(match&&id.equals(e.path("EntityId").asText())){ + return e; + }}throw notFound(kind,id);} + private static boolean isProduct(JsonNode e){String t=e.path("EntityType").asText();return t.contains("Product")&&!t.contains("Offer");} + private ObjectNode details(JsonNode e){JsonNode d=e.get("DetailsDocument");return d!=null&&d.isObject()?(ObjectNode)d.deepCopy():mapper.createObjectNode();} + private static String title(JsonNode e){JsonNode d=e.path("DetailsDocument");for (String k : List.of("ProductTitle", "Name", "Title", "OfferName", "OfferSetName")) { + if (d.hasNonNull(k) && !d.path(k).asText().isBlank()) { + return d.path(k).asText(); + } + }return e.path("EntityIdentifier").asText(e.path("EntityId").asText());} + private static String string(JsonNode n,String field,String fallback){return n.hasNonNull(field)&&!n.path(field).asText().isBlank()?n.path(field).asText():fallback;} + private ArrayNode stringArray(JsonNode n){if(n!=null&&n.isArray()){ + return (ArrayNode)n.deepCopy(); + }return mapper.createArrayNode();} + private ArrayNode array(JsonNode n){ArrayNode a=mapper.createArrayNode();a.add(n);return a;} + private static List nodes(JsonNode n){List out=new ArrayList<>();if(n!=null&&n.isArray()){ + n.forEach(out::add); + }return out;} + private ObjectNode page(String field,List values,JsonNode request,int def,int maxAllowed){int max=request.path("maxResults").isInt()?request.path("maxResults").asInt():def;if(max<1||max>maxAllowed){ + throw validation("maxResults must be between 1 and "+maxAllowed+"."); + }int off=token(request.path("nextToken").asText(null));if(off>values.size()){ + throw validation("nextToken is invalid."); + }int end=Math.min(values.size(),off+max);ObjectNode out=mapper.createObjectNode();ArrayNode a=out.putArray(field);values.subList(off,end).forEach(v->a.add(v.deepCopy()));if(end255||!v.matches("[\\w-]+")){ + throw validation(f+" is required and must match [\\w-]+."); + }return v;} + private static void validateRegion(String region){if(!REGIONS.contains(region)){ + throw validation("Marketplace Discovery is available only in us-east-1, us-west-2, and eu-west-1."); + }} + private static AwsException validation(String m){return new AwsException("ValidationException",m,400);} + private static AwsException notFound(String k,String id){return new AwsException("ResourceNotFoundException",k+" "+id+" was not found.",404);} + + private record FacetValue(String type, JsonNode value) {} + + @Override + public void clear() { + entities.clear(); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/marketplace/model/MarketplaceListingSummary.java b/src/main/java/io/github/hectorvent/floci/services/marketplace/model/MarketplaceListingSummary.java new file mode 100644 index 0000000000..0e41749941 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/marketplace/model/MarketplaceListingSummary.java @@ -0,0 +1,9 @@ +package io.github.hectorvent.floci.services.marketplace.model; + +/** Core sortable listing attributes used by Marketplace Discovery. */ +public record MarketplaceListingSummary( + String listingId, + String listingName, + double averageCustomerRating, + String deployedOnAws, + int numberOfProducts) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryIntegrationTest.java new file mode 100644 index 0000000000..3543c8f84b --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryIntegrationTest.java @@ -0,0 +1,95 @@ +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.ObjectNode; +import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend; +import io.github.hectorvent.floci.core.storage.StorageFactory; +import io.github.hectorvent.floci.testing.RestAssuredJsonUtils; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.notNullValue; + +@QuarkusTest +class MarketplaceDiscoveryIntegrationTest { + @Inject + StorageFactory storageFactory; + + @Inject + ObjectMapper mapper; + + private AccountAwareStorageBackend entities; + + @BeforeAll + static void configure() { + RestAssuredJsonUtils.configureAwsContentTypes(); + } + + @BeforeEach + void setUp() { + entities = storageFactory.create("marketplace", "marketplace-entities.json", + new TypeReference>() {}); + entities.clear(); + } + + @Test + void storedCatalogProductIsDiscoverableThroughBuyerApis() { + String productId = createProduct("Discovery product"); + + given().contentType("application/json").header("Authorization", auth()) + .body("{\"productId\":\"" + productId + "\"}") + .post("/2026-02-05/getProduct").then().statusCode(200) + .body("productId", equalTo(productId)).body("productName", equalTo("Discovery product")); + given().contentType("application/json").header("Authorization", auth()) + .body("{\"listingId\":\"" + productId + "\"}") + .post("/2026-02-05/getListing").then().statusCode(200) + .body("listingId", equalTo(productId)).body("publisher.displayName", notNullValue()); + given().contentType("application/json").header("Authorization", auth()) + .body("{\"searchText\":\"Discovery product\"}") + .post("/2026-02-05/searchListings").then().statusCode(200) + .body("totalResults", greaterThanOrEqualTo(1)); + } + + @Test + void unsupportedDiscoveryRegionIsRejected() { + given().contentType("application/json").header("Authorization", auth("ap-southeast-2")) + .body("{\"searchText\":\"anything\"}") + .post("/2026-02-05/searchListings").then().statusCode(400); + } + + private String createProduct(String title) { + String productId = "prod-discovery-local"; + ObjectNode entity = mapper.createObjectNode(); + entity.put("EntityType", "SaaSProduct@1.0"); + entity.put("EntityIdentifier", productId); + entity.put("EntityId", productId); + entity.put("EntityArn", "arn:aws:aws-marketplace:us-east-1:000000000000:AWSMarketplace/SaaSProduct/" + productId); + entity.put("LastModifiedDate", Instant.now().toString()); + ObjectNode details = entity.putObject("DetailsDocument"); + details.put("ProductTitle", title); + details.put("ShortDescription", "Local product"); + details.put("FulfillmentType", "SAAS"); + entities.put(productId, entity); + return productId; + } + + private static String auth() { + return auth("us-east-1"); + } + + private static String auth(String region) { + return "AWS4-HMAC-SHA256 Credential=000000000000/20260908/" + region + + "/aws-marketplace/aws4_request"; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryServiceTest.java new file mode 100644 index 0000000000..cf1b6ddabe --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryServiceTest.java @@ -0,0 +1,125 @@ +package io.github.hectorvent.floci.services.marketplace; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MarketplaceDiscoveryServiceTest { + private final ObjectMapper mapper = new ObjectMapper(); + private AccountAwareStorageBackend entities; + private MarketplaceDiscoveryService service; + + @BeforeEach + void setUp() { + entities = AccountAwareStorageBackend.inMemory("000000000000"); + service = new MarketplaceDiscoveryService(mapper, entities); + } + + @Test + void getOfferTermsReturnsStoredCatalogTerms() throws Exception { + entities.put("offer-1", mapper.readTree(""" + {"EntityType":"Offer@1.0","EntityId":"offer-1","DetailsDocument":{ + "OfferTerms":[{"type":"LegalTerm","id":"term-1"}]}} + """)); + JsonNode response = service.getOfferTerms( + mapper.readTree("{\"offerId\":\"offer-1\"}"), "us-east-1"); + assertEquals("term-1", response.path("offerTerms").get(0).path("id").asText()); + } + + @Test + void ratingSortAndFacetsUseListingValues() throws Exception { + entities.put("prod-low", product("prod-low", "Low", "1.0", "NOT_DEPLOYED", 2)); + entities.put("prod-high", product("prod-high", "High", "4.8", "DEPLOYED", 5)); + JsonNode sorted = service.searchListings(mapper.readTree( + "{\"sortBy\":\"AVERAGE_CUSTOMER_RATING\",\"sortOrder\":\"DESCENDING\"}"), "us-east-1"); + assertEquals("prod-high", sorted.path("listingSummaries").get(0).path("listingId").asText()); + + JsonNode facets = service.searchFacets(mapper.readTree( + "{\"facetTypes\":[\"DEPLOYED_ON_AWS\",\"NUMBER_OF_PRODUCTS\"]}"), "us-east-1"); + assertEquals(2, facets.path("listingFacets").path("DEPLOYED_ON_AWS").size()); + assertEquals(2, facets.path("listingFacets").path("NUMBER_OF_PRODUCTS").size()); + } + + + + @Test + void rejectsSearchFilterValueCountAboveAwsLimit() throws Exception { + StringBuilder values = new StringBuilder(); + for (int i = 0; i < 31; i++) { + if (i > 0) { + values.append(','); + } + values.append('\"').append("value").append(i).append('\"'); + } + JsonNode request = mapper.readTree("{\"filters\":[{\"filterType\":\"CATEGORY\",\"filterValues\":[" + + values + "]}]}"); + + assertThrows(RuntimeException.class, () -> service.searchListings(request, "us-east-1")); + } + + @Test + void rejectsPurchaseOptionFilterCountAboveAwsLimit() throws Exception { + StringBuilder filters = new StringBuilder(); + for (int i = 0; i < 11; i++) { + if (i > 0) { + filters.append(','); + } + filters.append("{\"filterType\":\"PRODUCT_ID\",\"filterValues\":[\"prod-") + .append(i).append("\"]}"); + } + JsonNode request = mapper.readTree("{\"filters\":[" + filters + "]}"); + + assertThrows(RuntimeException.class, () -> service.listPurchaseOptions(request, "us-east-1")); + } + + @Test + void searchTextIgnoresFacetOnlyMetadata() throws Exception { + entities.put("prod-1", product("prod-1", "Visible Product", "4.0", "DEPLOYED", 3)); + + JsonNode deployed = service.searchListings( + mapper.readTree("{\"searchText\":\"DEPLOYED\"}"), "us-east-1"); + JsonNode count = service.searchListings( + mapper.readTree("{\"searchText\":\"3\"}"), "us-east-1"); + JsonNode visible = service.searchListings( + mapper.readTree("{\"searchText\":\"Visible Product\"}"), "us-east-1"); + + assertEquals(0, deployed.path("listingSummaries").size()); + assertEquals(0, count.path("listingSummaries").size()); + assertEquals(1, visible.path("listingSummaries").size()); + } + + @Test + void searchListingsDoesNotLeakFacetOnlyFields() throws Exception { + entities.put("prod-1", product("prod-1", "Product", "4.0", "DEPLOYED", 3)); + + JsonNode listing = service.searchListings(mapper.createObjectNode(), "us-east-1") + .path("listingSummaries").get(0); + + assertFalse(listing.has("deployedOnAws")); + assertFalse(listing.has("numberOfProducts")); + assertFalse(listing.has("_deployedOnAws")); + assertFalse(listing.has("_numberOfProducts")); + } + + @Test + void clearResetsSharedMarketplaceEntityState() throws Exception { + entities.put("prod-1", product("prod-1", "Product", "4.0", "DEPLOYED", 1)); + + service.clear(); + + assertEquals(0, entities.scan(key -> true).size()); + } + + private JsonNode product(String id, String title, String rating, String deployed, int count) throws Exception { + return mapper.readTree("{\"EntityType\":\"SaaSProduct@1.0\",\"EntityId\":\"" + id + + "\",\"DetailsDocument\":{\"ProductTitle\":\"" + title + + "\",\"AverageCustomerRating\":\"" + rating + "\",\"DeployedOnAws\":\"" + + deployed + "\",\"NumberOfProducts\":" + count + "}}"); + } +} diff --git a/tools/docs/services.yaml b/tools/docs/services.yaml index 19202d5d42..9dd8d19801 100644 --- a/tools/docs/services.yaml +++ b/tools/docs/services.yaml @@ -88,6 +88,7 @@ services: - { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementService.java, mode: switch } - { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceEntitlementController.java, mode: switch } - { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDeploymentController.java, mode: rest } + - { path: src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceDiscoveryController.java, mode: rest } exclude_actions: - AgreementType - Status