Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions api/src/main/java/com/okta/sdk/resource/common/PagedList.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,27 +67,29 @@ public String getAfter() {
public static <T> T constructPagedList(HttpResponse response, T value) {
Assert.notNull(response);
Assert.isTrue(value instanceof List);
Header[] linkHeaders = response.getHeaders("link");
if (linkHeaders == null || linkHeaders.length == 0) {
if (value instanceof PagedList) {
return value;
}
String nextPage = null;
String self = null;
for (Header link : linkHeaders) {
String[] parts = link.getValue().split("; *");
String url = parts[0]
.replaceAll("<", "")
.replaceAll(">", "");
String rel = parts[1];
if (rel.equals("rel=\"next\"")) {
nextPage = url;
} else if (rel.equals("rel=\"self\"")) {
self = url;
Header[] linkHeaders = response.getHeaders("link");
if (linkHeaders != null) {
for (Header link : linkHeaders) {
String[] parts = link.getValue().split("; *");
String url = parts[0]
.replaceAll("<", "")
.replaceAll(">", "");
String rel = parts[1];
if (rel.equals("rel=\"next\"")) {
nextPage = url;
} else if (rel.equals("rel=\"self\"")) {
self = url;
}
}
}
if (nextPage == null && self == null) {
return value;
}
// Always wrap in a PagedList, even when there's no Link header (e.g. a filtered
// query that fits on a single page), so callers get a consistent return type.
// getAfter()/hasMoreItems() already report "no more pages" when nextPage is null.
return (T) new PagedList((List) value, self, nextPage, null);
}

Expand Down
53 changes: 53 additions & 0 deletions api/src/main/resources/custom_templates/ApiClient.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
import com.fasterxml.jackson.databind.ser.PropertyWriter;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
{{#joda}}
import com.fasterxml.jackson.datatype.joda.JodaModule;
{{/joda}}
Expand Down Expand Up @@ -125,6 +129,43 @@ public class ApiClient{{#jsr310}} extends JavaTimeFormatter{{/jsr310}} {

private static final Logger log = LoggerFactory.getLogger(ApiClient.class);

/**
* Some generated models (e.g. ListJwk200ResponseInner, SamlAttributeStatement) declare
* {@literal @}JsonSubTypes entries pointing at classes that don't actually extend them, because their
* source schemas use oneOf/anyOf + discriminator without allOf-based inheritance. Jackson requires real
* Java inheritance for polymorphic resolution, so deserializing these throws InvalidTypeIdException.
* These classes already contain the union of all their branches' properties as flat fields, so disabling
* polymorphic resolution loses no data.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
private interface NoPolymorphicTypeInfoMixin {
}

/**
* Required properties on generated models get {@literal @}JsonInclude(ALWAYS) so create payloads always
* include them. But Application subtypes (e.g. OpenIdConnectApplication) reuse the same class for partial
* PUT updates, where ALWAYS forces unrelated required fields (credentials, name, settings) to serialize as
* literal null, overwriting them server-side. Jackson property filters run before per-property
* JsonInclude, so this filter restores NON_NULL behavior for Application and all its subtypes.
*/
@JsonFilter("applicationNullFieldFilter")
private interface ApplicationNullFieldFilterMixin {
}

private static final class SkipNullPropertyFilter extends SimpleBeanPropertyFilter {
@Override
public void serializeAsField(Object pojo, com.fasterxml.jackson.core.JsonGenerator jgen,
SerializerProvider provider, PropertyWriter writer) throws Exception {
if (writer instanceof BeanPropertyWriter && ((BeanPropertyWriter) writer).get(pojo) == null) {
if (!jgen.canOmitFields()) {
writer.serializeAsOmittedField(pojo, jgen, provider);
}
return;
}
super.serializeAsField(pojo, jgen, provider, writer);
}
}

private Map<String, String> defaultHeaderMap = new HashMap<String, String>();
private Map<String, String> defaultCookieMap = new HashMap<String, String>();
private String basePath = "{{{basePath}}}";
Expand Down Expand Up @@ -195,6 +236,18 @@ protected List<ServerConfiguration> servers = new ArrayList<ServerConfiguration>
objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING);
objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);

// OKTA-1227472: disable broken polymorphic type resolution on models whose spec-declared
// discriminator subtypes don't actually extend them.
objectMapper.addMixIn(com.okta.sdk.resource.model.ListJwk200ResponseInner.class, NoPolymorphicTypeInfoMixin.class);
objectMapper.addMixIn(com.okta.sdk.resource.model.SamlAttributeStatement.class, NoPolymorphicTypeInfoMixin.class);

// OKTA-1218351: restore NON_NULL serialization on Application and its subtypes, overriding the
// per-property JsonInclude(ALWAYS) generated for their required properties.
objectMapper.addMixIn(com.okta.sdk.resource.model.Application.class, ApplicationNullFieldFilterMixin.class);
objectMapper.setFilterProvider(new SimpleFilterProvider()
.addFilter("applicationNullFieldFilter", new SkipNullPropertyFilter())
.setFailOnUnknownId(false));
{{#joda}}
objectMapper.registerModule(new JodaModule());
{{/joda}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright 2026-Present Okta, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.okta.sdk.resource.client;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.okta.sdk.cache.Cache;
import com.okta.sdk.cache.CacheManager;
import com.okta.sdk.resource.model.ApplicationVisibility;
import com.okta.sdk.resource.model.ApplicationVisibilityHide;
import com.okta.sdk.resource.model.ListJwk200ResponseInner;
import com.okta.sdk.resource.model.OpenIdConnectApplication;
import com.okta.sdk.resource.model.SamlAttributeStatement;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.testng.annotations.Test;

import java.util.List;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;

/**
* Unit tests for the Jackson mixins registered in {@link ApiClient}'s default {@code ObjectMapper}.
*/
public class ApiClientJacksonMixinTest {

// A minimal no-op CacheManager, so this test doesn't need the okta-sdk-impl module (which would
// introduce a circular dependency back onto this api module) just to construct an ApiClient.
private static final CacheManager NOOP_CACHE_MANAGER = new CacheManager() {
@Override
public <K, V> Cache<K, V> getCache(String name) {
return new Cache<K, V>() {
@Override
public V get(K key) {
return null;
}

@Override
public V put(K key, V value) {
return null;
}

@Override
public V remove(K key) {
return null;
}
};
}
};

private final ObjectMapper objectMapper =
new ApiClient(HttpClients.createDefault(), NOOP_CACHE_MANAGER).getObjectMapper();

/**
* OKTA-1227472: ListJwk200ResponseInner declares @JsonSubTypes entries for classes that don't actually
* extend it, which used to throw InvalidTypeIdException. The mixin disables polymorphic resolution so
* the flat class (which already has every branch's properties) is used directly.
*/
@Test
public void deserializeListJwkResponse_withMixedSigAndEncEntries_doesNotThrow() throws Exception {
String json = "["
+ "{\"kid\":\"kid1\",\"status\":\"ACTIVE\",\"kty\":\"RSA\",\"use\":\"sig\",\"id\":\"pks1\"},"
+ "{\"e\":\"AQAB\",\"kty\":\"RSA\",\"n\":\"mkC6\",\"use\":\"enc\"}"
+ "]";

List<ListJwk200ResponseInner> keys = objectMapper.readValue(json,
new TypeReference<List<ListJwk200ResponseInner>>() { });

assertEquals(keys.size(), 2);
assertEquals(keys.get(0).getKid(), "kid1");
assertEquals(keys.get(1).getE(), "AQAB");
}

/**
* OKTA-1227472: same defect on SamlAttributeStatement (EXPRESSION/GROUP anyOf without allOf inheritance).
*/
@Test
public void deserializeSamlAttributeStatement_withExpressionAndGroupEntries_doesNotThrow() throws Exception {
String json = "["
+ "{\"type\":\"EXPRESSION\",\"name\":\"email\",\"values\":[\"user.email\"]},"
+ "{\"type\":\"GROUP\",\"filterType\":\"STARTS_WITH\",\"filterValue\":\"Team\"}"
+ "]";

List<SamlAttributeStatement> statements = objectMapper.readValue(json,
new TypeReference<List<SamlAttributeStatement>>() { });

assertEquals(statements.size(), 2);
assertEquals(statements.get(0).getType(), SamlAttributeStatement.TypeEnum.EXPRESSION);
assertEquals(statements.get(0).getName(), "email");
assertEquals(statements.get(1).getType(), SamlAttributeStatement.TypeEnum.GROUP);
assertEquals(statements.get(1).getFilterValue(), "Team");
}

/**
* OKTA-1218351: OpenIdConnectApplication marks credentials/name/settings as required, which generates
* @JsonInclude(ALWAYS) on those properties. A partial update (only visibility set) must not serialize
* them as literal null, or the API overwrites/rejects the update.
*/
@Test
public void serializePartialOpenIdConnectApplication_omitsNullRequiredFields() throws Exception {
OpenIdConnectApplication app = new OpenIdConnectApplication();
ApplicationVisibilityHide hide = new ApplicationVisibilityHide().web(true).iOS(true);
app.setVisibility(new ApplicationVisibility().hide(hide).autoSubmitToolbar(true));

String json = objectMapper.writeValueAsString(app);

assertFalse(json.contains("\"credentials\""), "credentials should be omitted, got: " + json);
assertFalse(json.contains("\"name\""), "name should be omitted, got: " + json);
assertFalse(json.contains("\"settings\""), "settings should be omitted, got: " + json);
assertTrue(json.contains("\"visibility\""), "visibility should be present, got: " + json);
}

@Test
public void serializeFullOpenIdConnectApplication_stillIncludesRequiredFields() throws Exception {
OpenIdConnectApplication app = new OpenIdConnectApplication();
app.setName(OpenIdConnectApplication.NameEnum.OIDC_CLIENT);

String json = objectMapper.writeValueAsString(app);

assertNotNull(json);
assertTrue(json.contains("\"name\""), "name should be present when set, got: " + json);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2026-Present Okta, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.okta.sdk.resource.common;

import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.message.BasicHttpResponse;
import org.testng.annotations.Test;

import java.util.Arrays;
import java.util.List;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;

/**
* Unit tests for {@link PagedList}, in particular {@link PagedList#constructPagedList}.
*
* OKTA-1217985: SDK list methods must always return a {@link PagedList}, even when a filter (e.g. {@code q})
* narrows the result to a single page and the response has no {@code Link} header. Consumers that cast the
* result to {@code PagedList} would otherwise get a {@code ClassCastException}.
*/
public class PagedListTest {

@Test
public void constructPagedList_withNoLinkHeader_returnsPagedListNotPlainList() {
HttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK);

List<String> value = Arrays.asList("a", "b");
Object result = PagedList.constructPagedList(response, value);

assertTrue(result instanceof PagedList, "expected a PagedList even without a Link header");
PagedList<?> pagedList = (PagedList<?>) result;
assertEquals(pagedList.size(), 2);
assertFalse(pagedList.hasMoreItems());
}

@Test
public void constructPagedList_withNextLinkHeader_returnsPagedListWithNextPage() {
BasicHttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK);
response.addHeader("link", "<https://example.okta.com/api/v1/users?after=abc123>; rel=\"next\"");

List<String> value = Arrays.asList("a", "b", "c");
Object result = PagedList.constructPagedList(response, value);

assertTrue(result instanceof PagedList);
PagedList<?> pagedList = (PagedList<?>) result;
assertEquals(pagedList.size(), 3);
assertTrue(pagedList.hasMoreItems());
assertEquals(pagedList.getAfter(), "abc123");
}

@Test
public void constructPagedList_withSelfLinkOnly_returnsPagedListWithNoMoreItems() {
BasicHttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK);
response.addHeader("link", "<https://example.okta.com/api/v1/users?after=xyz>; rel=\"self\"");

List<String> value = Arrays.asList("a");
Object result = PagedList.constructPagedList(response, value);

assertTrue(result instanceof PagedList);
PagedList<?> pagedList = (PagedList<?>) result;
assertEquals(pagedList.getSelf(), "https://example.okta.com/api/v1/users?after=xyz");
assertFalse(pagedList.hasMoreItems());
}

@Test
public void constructPagedList_withAlreadyPagedList_returnsSameInstance() {
HttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK);
PagedList<String> existing = new PagedList<>(Arrays.asList("a"), null, null, null);

Object result = PagedList.constructPagedList(response, existing);

assertTrue(result == existing);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public GroupProfile deserialize(JsonParser jp, DeserializationContext ctxt) thro
break;

default:
break;
groupProfile.getAdditionalProperties().put(key, value);
}
}

Expand Down
Loading