diff --git a/src/main/java/com/stripe/model/StripeContext.java b/src/main/java/com/stripe/model/StripeContext.java new file mode 100644 index 00000000000..f62fbfe7cdf --- /dev/null +++ b/src/main/java/com/stripe/model/StripeContext.java @@ -0,0 +1,83 @@ +package com.stripe.model; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * StripeContext represents a context path for Stripe API requests. + * + * The context is used to access child accounts by adding segments, + * or parent accounts by removing segments. This class provides an + * immutable interface for manipulating context paths. + */ +public class StripeContext { + private final List segments; + + /** + * Initialize a StripeContext with no segments. + */ + public StripeContext() { + this.segments = Collections.emptyList(); + } + + /** + * Initialize a StripeContext with the given segments. + * + * @param segments List of context path segments + */ + public StripeContext(List segments) { + this.segments = segments != null ? + Collections.unmodifiableList(new ArrayList<>(segments)) : + Collections.emptyList(); + } + + /** + * Parse a context string into a StripeContext instance. + * + * @param contextString A string like "a/b/c" to be split on "/" + * @return A new StripeContext instance with the parsed segments + */ + public static StripeContext parse(String contextString) { + if (contextString == null || contextString.isEmpty()) { + return new StripeContext(); + } + return new StripeContext(Arrays.asList(contextString.split("/"))); + } + + /** + * Create a new StripeContext with the last segment removed. + * + * @return A new StripeContext instance with one fewer segment + * @throws IllegalStateException If context has no segments to remove + */ + public StripeContext parent() { + if (segments.isEmpty()) { + throw new IllegalStateException("Cannot get parent of empty context"); + } + return new StripeContext(segments.subList(0, segments.size() - 1)); + } + + /** + * Create a new StripeContext with an additional segment appended. + * + * @param segment The segment to append to the context path + * @return A new StripeContext instance with the new segment added + */ + public StripeContext child(String segment) { + List newSegments = new ArrayList<>(segments); + newSegments.add(segment); + return new StripeContext(newSegments); + } + + /** + * Convert the StripeContext to its string representation. + * + * @return A string with segments joined by "/" + */ + @Override + public String toString() { + return String.join("/", segments); + } +} \ No newline at end of file diff --git a/src/main/java/com/stripe/model/ThinEvent.java b/src/main/java/com/stripe/model/ThinEvent.java index 5b7bd129c17..e436bc056e4 100644 --- a/src/main/java/com/stripe/model/ThinEvent.java +++ b/src/main/java/com/stripe/model/ThinEvent.java @@ -30,7 +30,7 @@ public class ThinEvent { /** [Optional] Authentication context needed to fetch the event or related object. */ @SerializedName("context") - public String context; + public StripeContext context; /** [Optional] Object containing the reference to API resource relevant to the event. */ @SerializedName("related_object") diff --git a/src/main/java/com/stripe/net/ApiResource.java b/src/main/java/com/stripe/net/ApiResource.java index 214803bcca7..ea62405625a 100644 --- a/src/main/java/com/stripe/net/ApiResource.java +++ b/src/main/java/com/stripe/net/ApiResource.java @@ -58,6 +58,7 @@ private static Gson createGson(boolean shouldSetResponseGetter) { .registerTypeAdapter(Instant.class, new InstantDeserializer()) .registerTypeAdapterFactory(new EventTypeAdapterFactory()) .registerTypeAdapter(StripeRawJsonObject.class, new StripeRawJsonObjectDeserializer()) + .registerTypeAdapter(StripeContext.class, new StripeContextDeserializer()) .registerTypeAdapterFactory(new StripeCollectionItemTypeSettingFactory()) .addReflectionAccessFilter( new ReflectionAccessFilter() { diff --git a/src/main/java/com/stripe/net/RequestOptions.java b/src/main/java/com/stripe/net/RequestOptions.java index db1e836db32..903855f6e72 100644 --- a/src/main/java/com/stripe/net/RequestOptions.java +++ b/src/main/java/com/stripe/net/RequestOptions.java @@ -1,6 +1,7 @@ package com.stripe.net; import com.stripe.Stripe; +import com.stripe.model.StripeContext; import java.net.PasswordAuthentication; import java.net.Proxy; import java.util.Map; @@ -235,6 +236,16 @@ public RequestOptionsBuilder setStripeContext(String context) { return this; } + public RequestOptionsBuilder setStripeContext(StripeContext context) { + if (context != null) { + String contextValue = context.toString(); + this.stripeContext = !contextValue.isEmpty() ? contextValue : null; + } else { + this.stripeContext = null; + } + return this; + } + public RequestOptionsBuilder clearStripeContext() { this.stripeContext = null; return this; diff --git a/src/main/java/com/stripe/net/StripeContextDeserializer.java b/src/main/java/com/stripe/net/StripeContextDeserializer.java new file mode 100644 index 00000000000..b0e7044a24c --- /dev/null +++ b/src/main/java/com/stripe/net/StripeContextDeserializer.java @@ -0,0 +1,21 @@ +package com.stripe.net; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.stripe.model.StripeContext; +import java.lang.reflect.Type; + +public class StripeContextDeserializer implements JsonDeserializer { + @Override + public StripeContext deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + if (json.isJsonNull()) { + return null; + } + + String contextString = json.getAsString(); + return StripeContext.parse(contextString); + } +} \ No newline at end of file diff --git a/src/test/java/com/stripe/model/StripeContextTest.java b/src/test/java/com/stripe/model/StripeContextTest.java new file mode 100644 index 00000000000..fbc8be5432a --- /dev/null +++ b/src/test/java/com/stripe/model/StripeContextTest.java @@ -0,0 +1,129 @@ +package com.stripe.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +public class StripeContextTest { + @Test + public void testEmptyContext() { + StripeContext context = new StripeContext(); + assertEquals("", context.toString()); + } + + @Test + public void testContextWithSegments() { + StripeContext context = new StripeContext(Arrays.asList("a", "b", "c")); + assertEquals("a/b/c", context.toString()); + } + + @Test + public void testParseEmptyString() { + StripeContext context = StripeContext.parse(""); + assertEquals("", context.toString()); + } + + @Test + public void testParseNullString() { + StripeContext context = StripeContext.parse(null); + assertEquals("", context.toString()); + } + + @Test + public void testParseSingleSegment() { + StripeContext context = StripeContext.parse("a"); + assertEquals("a", context.toString()); + } + + @Test + public void testParseMultipleSegments() { + StripeContext context = StripeContext.parse("a/b/c"); + assertEquals("a/b/c", context.toString()); + } + + @Test + public void testParentReturnsNewInstance() { + StripeContext context = StripeContext.parse("a/b/c"); + StripeContext parent = context.parent(); + + // Original unchanged + assertEquals("a/b/c", context.toString()); + // New instance with removed segment + assertEquals("a/b", parent.toString()); + } + + @Test + public void testParentOfSingleSegment() { + StripeContext context = StripeContext.parse("a"); + StripeContext parent = context.parent(); + assertEquals("", parent.toString()); + } + + @Test + public void testParentOfEmptyContextThrowsException() { + StripeContext context = new StripeContext(); + assertThrows(IllegalStateException.class, () -> context.parent(), + "Cannot get parent of empty context"); + } + + @Test + public void testChildReturnsNewInstance() { + StripeContext context = StripeContext.parse("a/b"); + StripeContext child = context.child("c"); + + // Original unchanged + assertEquals("a/b", context.toString()); + // New instance with added segment + assertEquals("a/b/c", child.toString()); + } + + @Test + public void testChildOnEmptyContext() { + StripeContext context = new StripeContext(); + StripeContext child = context.child("a"); + assertEquals("a", child.toString()); + } + + @Test + public void testMethodChaining() { + StripeContext context = StripeContext.parse("a"); + StripeContext result = context.child("b").child("c").parent(); + assertEquals("a/b", result.toString()); + } + + @Test + public void testInitWithNullSegments() { + StripeContext context = new StripeContext(null); + assertEquals("", context.toString()); + } + + @Test + public void testInitWithEmptyList() { + StripeContext context = new StripeContext(Collections.emptyList()); + assertEquals("", context.toString()); + } + + @Test + public void testEmptyContextDoesNotSetHeader() { + StripeContext emptyContext = new StripeContext(); + RequestOptions options = RequestOptions.builder() + .setStripeContext(emptyContext) + .build(); + + // Empty context should result in null stripeContext + assertNull(options.getStripeContext()); + } + + @Test + public void testNonEmptyContextSetsHeader() { + StripeContext context = StripeContext.parse("org_123/proj_456"); + RequestOptions options = RequestOptions.builder() + .setStripeContext(context) + .build(); + + // Non-empty context should set the header + assertEquals("org_123/proj_456", options.getStripeContext()); + } +} \ No newline at end of file