diff --git a/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java b/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java index b3270d541190..86238e56514b 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java @@ -3,6 +3,7 @@ import com.dotcms.contenttype.model.type.BaseContentType; import com.dotcms.graphql.datafetcher.BinaryFieldDataFetcher; import com.dotcms.graphql.datafetcher.FieldDataFetcher; +import com.dotcms.graphql.datafetcher.FileAssetBinaryPropertyDataFetcher; import com.dotcms.graphql.datafetcher.KeyValueFieldDataFetcher; import com.dotcms.graphql.datafetcher.MapFieldPropertiesDataFetcher; import com.dotcms.graphql.datafetcher.MultiValueFieldDataFetcher; @@ -11,15 +12,19 @@ import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.fileassets.business.FileAsset; import graphql.scalars.ExtendedScalars; +import graphql.schema.GraphQLInterfaceType; import graphql.schema.GraphQLList; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLType; import graphql.schema.GraphQLTypeReference; import graphql.schema.PropertyDataFetcher; +import graphql.schema.TypeResolver; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Function; @@ -61,7 +66,49 @@ public String getTypeName() { private static Map customFieldTypes = new HashMap<>(); + /** Name of the shared interface exposed by every binary-bearing custom type. See #34540. */ + public static final String DOT_BINARY_LIKE_INTERFACE_NAME = "DotBinaryLike"; + + private static GraphQLInterfaceType dotBinaryLikeInterface; + static { + // Build the shared DotBinaryLike interface first so the concrete types below can + // declare it as an implemented interface. The TypeResolver picks the concrete + // GraphQL type at query time based on the Java type of the source value: + // - Map -> DotBinary (produced by BinaryToMapTransformer) + // - Contentlet -> DotFileasset (produced by FileFieldDataFetcher) + final Map commonBinaryFields = new LinkedHashMap<>(); + commonBinaryFields.put("name", GraphQLString); + commonBinaryFields.put("title", GraphQLString); + commonBinaryFields.put("size", GraphQLLong); + commonBinaryFields.put("mime", GraphQLString); + commonBinaryFields.put("versionPath", GraphQLString); + commonBinaryFields.put("idPath", GraphQLString); + commonBinaryFields.put("path", GraphQLString); + commonBinaryFields.put("sha256", GraphQLString); + commonBinaryFields.put("isImage", GraphQLBoolean); + commonBinaryFields.put("width", GraphQLLong); + commonBinaryFields.put("height", GraphQLLong); + commonBinaryFields.put("modDate", GraphQLLong); + + final Map dotBinaryLikeFields = new LinkedHashMap<>(); + commonBinaryFields.forEach((name, type) -> + dotBinaryLikeFields.put(name, new TypeFetcher(type))); + + final TypeResolver dotBinaryLikeResolver = env -> { + final Object source = env.getObject(); + if (source instanceof Map) { + return customFieldTypes.get("BINARY"); + } + if (source instanceof Contentlet) { + return customFieldTypes.get("FILEASSET"); + } + return null; + }; + + dotBinaryLikeInterface = TypeUtil.createInterfaceType( + DOT_BINARY_LIKE_INTERFACE_NAME, dotBinaryLikeFields, dotBinaryLikeResolver); + final Map binaryTypeFields = new HashMap<>(); binaryTypeFields.put("versionPath", GraphQLString); binaryTypeFields.put("idPath", GraphQLString); @@ -77,7 +124,7 @@ public String getTypeName() { binaryTypeFields.put("modDate", GraphQLLong); binaryTypeFields.put("focalPoint", GraphQLString); customFieldTypes.put("BINARY", TypeUtil.createObjectType(BINARY.getTypeName(), binaryTypeFields, - new MapFieldPropertiesDataFetcher())); + new MapFieldPropertiesDataFetcher(), dotBinaryLikeInterface)); final Map categoryTypeFields = new HashMap<>(); categoryTypeFields.put("inode", GraphQLID); @@ -158,7 +205,17 @@ public String getTypeName() { new TypeFetcher(list(CustomFieldType.KEY_VALUE.getType()), new KeyValueFieldDataFetcher())); fileAssetTypeFields.put(FILEASSET_SHOW_ON_MENU_FIELD_VAR, new TypeFetcher(list(GraphQLString), new MultiValueFieldDataFetcher())); fileAssetTypeFields.put(FILEASSET_SORT_ORDER_FIELD_VAR, new TypeFetcher(GraphQLInt, new FieldDataFetcher())); - customFieldTypes.put("FILEASSET", TypeUtil.createObjectType(FILEASSET.getTypeName(), fileAssetTypeFields)); + + // DotBinaryLike interface fields — resolved from the underlying FileAsset/DotAsset + // Contentlet via BinaryToMapTransformer so the values match those exposed by + // the equivalent query against a raw BinaryField. See issue #34540. + final FileAssetBinaryPropertyDataFetcher binaryPropertyFetcher = + new FileAssetBinaryPropertyDataFetcher(); + commonBinaryFields.forEach((name, type) -> + fileAssetTypeFields.put(name, new TypeFetcher(type, binaryPropertyFetcher))); + + customFieldTypes.put("FILEASSET", TypeUtil.createObjectType(FILEASSET.getTypeName(), + fileAssetTypeFields, dotBinaryLikeInterface)); final Map siteTypeFields = new HashMap<>(ContentFields.getContentFields()); siteTypeFields.remove(HOST_KEY); // remove myself @@ -188,6 +245,20 @@ public static Collection getCustomFieldTypes() { return customFieldTypes.values(); } + /** + * Returns the shared {@code DotBinaryLike} interface implemented by {@code DotBinary} and + * {@code DotFileasset}. Callers registering the GraphQL schema must include this in the set + * of known types so introspection exposes it. See issue #34540. + */ + public static GraphQLInterfaceType getDotBinaryLikeInterface() { + return dotBinaryLikeInterface; + } + + /** @return all GraphQL interface types owned by this enum (currently just DotBinaryLike). */ + public static Collection getCustomFieldInterfaces() { + return Collections.singletonList(dotBinaryLikeInterface); + } + public static boolean isCustomFieldType(final GraphQLType type) { boolean isCustomField = false; diff --git a/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java b/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java index c8d01befb8d6..704a4303784f 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java @@ -153,6 +153,7 @@ private Set getContentAPITypes() throws DotDataException { final Set contentAPITypes = new HashSet<>(InterfaceType.valuesAsSet()); contentAPITypes.addAll(CustomFieldType.getCustomFieldTypes()); + contentAPITypes.addAll(CustomFieldType.getCustomFieldInterfaces()); List allTypes = APILocator.getContentTypeAPI(APILocator.systemUser()) .search("", null, 100000, 0); diff --git a/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FileAssetBinaryPropertyDataFetcher.java b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FileAssetBinaryPropertyDataFetcher.java new file mode 100644 index 000000000000..cca16c322edc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FileAssetBinaryPropertyDataFetcher.java @@ -0,0 +1,56 @@ +package com.dotcms.graphql.datafetcher; + +import static com.dotcms.contenttype.model.type.BaseContentType.DOTASSET; + +import com.dotcms.contenttype.model.type.FileAssetContentType; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.contentlet.transform.BinaryToMapTransformer; +import com.dotmarketing.util.Logger; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Map; + +/** + * Resolves a single {@code DotBinaryLike} interface field (e.g. {@code versionPath}, + * {@code size}, {@code mime}) against a {@link Contentlet} that represents a FileAsset or a + * DotAsset. + * + *

The backing data is produced by {@link BinaryToMapTransformer}, which is the same + * transformer used by {@code BinaryFieldDataFetcher} for raw {@code BinaryField}s. That guarantees + * the values returned here are identical to those a client would get by querying the equivalent + * {@code mybinary} field on a Binary-typed content property — which is the whole point of the + * unified interface. + * + *

The binary map key depends on the underlying contentlet's base type: {@code "assetMap"} for + * {@code DotAsset} and {@code "fileAssetMap"} for {@code FileAsset}. + */ +public class FileAssetBinaryPropertyDataFetcher implements DataFetcher { + + @Override + public Object get(final DataFetchingEnvironment environment) throws Exception { + final Contentlet contentlet = environment.getSource(); + if (contentlet == null) { + return null; + } + try { + final String fieldName = environment.getField().getName(); + final Map binaryMap = resolveBinaryMap(contentlet); + return binaryMap != null ? binaryMap.get(fieldName) : null; + } catch (IllegalArgumentException e) { + Logger.warn(this, "Binary is null for field: " + environment.getField().getName()); + return null; + } catch (Exception e) { + Logger.error(this, e.getMessage(), e); + throw e; + } + } + + @SuppressWarnings("unchecked") + private Map resolveBinaryMap(final Contentlet contentlet) { + final String var = contentlet.getContentType().baseType() == DOTASSET + ? "asset" + : FileAssetContentType.FILEASSET_FILEASSET_FIELD_VAR; + final BinaryToMapTransformer transformer = new BinaryToMapTransformer(contentlet); + return (Map) transformer.asMap().get(var + "Map"); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java b/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java index f1f83e72932a..04bc166b6682 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java @@ -32,17 +32,30 @@ public class TypeUtil { public static GraphQLObjectType createObjectType(final String typeName, final Map typeFields, final DataFetcher dataFetcher) { + return createObjectType(typeName, typeFields, dataFetcher, (GraphQLInterfaceType[]) null); + } + + public static GraphQLObjectType createObjectType(final String typeName, + final Map typeFields, + final DataFetcher dataFetcher, + final GraphQLInterfaceType... interfaces) { Map fieldsTypesAndFetchersMap = typeFields.entrySet() .stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> new TypeFetcher(entry.getValue(), dataFetcher))); - return createObjectType(typeName, fieldsTypesAndFetchersMap); + return createObjectType(typeName, fieldsTypesAndFetchersMap, interfaces); } public static GraphQLObjectType createObjectType(final String typeName, final Map fieldsTypesAndFetchers) { + return createObjectType(typeName, fieldsTypesAndFetchers, (GraphQLInterfaceType[]) null); + } + + public static GraphQLObjectType createObjectType(final String typeName, + final Map fieldsTypesAndFetchers, + final GraphQLInterfaceType... interfaces) { final GraphQLObjectType.Builder builder = GraphQLObjectType.newObject().name(typeName); @@ -51,6 +64,14 @@ public static GraphQLObjectType createObjectType(final String typeName, builder.fields(fieldDefinitionList); + if (interfaces != null) { + for (final GraphQLInterfaceType iface : interfaces) { + if (iface != null) { + builder.withInterface(iface); + } + } + } + return builder.build(); } diff --git a/dotCMS/src/test/java/com/dotcms/graphql/CustomFieldTypeBinaryLikeTest.java b/dotCMS/src/test/java/com/dotcms/graphql/CustomFieldTypeBinaryLikeTest.java new file mode 100644 index 000000000000..4181f7d37fff --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/graphql/CustomFieldTypeBinaryLikeTest.java @@ -0,0 +1,128 @@ +package com.dotcms.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import graphql.Scalars; +import graphql.scalars.ExtendedScalars; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLInterfaceType; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLOutputType; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for issue #34540: unify GraphQL queries across Binary, File, and Image fields. + * + *

Today, a {@code BinaryField} maps to the {@code DotBinary} GraphQL type and a + * {@code FileField}/{@code ImageField} maps to the {@code DotFileasset} GraphQL type. The two + * types expose different sub-fields, so a client cannot query them uniformly. This test + * asserts the new contract: both concrete types implement a shared {@code DotBinaryLike} + * interface and expose the same common set of binary properties at the top level. + */ +class CustomFieldTypeBinaryLikeTest { + + private static final String INTERFACE_NAME = "DotBinaryLike"; + + /** The 12 properties the shared interface must expose on every binary-bearing type. */ + private static final List COMMON_BINARY_FIELDS = Arrays.asList( + "name", "title", "size", "mime", "versionPath", "idPath", "path", + "sha256", "isImage", "width", "height", "modDate"); + + @Test + void dotBinary_implementsDotBinaryLikeInterface() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + assertTrue( + dotBinary.getInterfaces().stream() + .anyMatch(i -> INTERFACE_NAME.equals(((GraphQLInterfaceType) i).getName())), + "DotBinary must implement the " + INTERFACE_NAME + " interface"); + } + + @Test + void dotFileasset_implementsDotBinaryLikeInterface() { + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + assertTrue( + dotFileasset.getInterfaces().stream() + .anyMatch(i -> INTERFACE_NAME.equals(((GraphQLInterfaceType) i).getName())), + "DotFileasset must implement the " + INTERFACE_NAME + " interface"); + } + + @Test + void dotBinary_exposesAllCommonBinaryFields() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + for (final String field : COMMON_BINARY_FIELDS) { + assertNotNull(dotBinary.getFieldDefinition(field), + "DotBinary must expose common field: " + field); + } + } + + @Test + void dotFileasset_exposesAllCommonBinaryFields() { + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + for (final String field : COMMON_BINARY_FIELDS) { + assertNotNull(dotFileasset.getFieldDefinition(field), + "DotFileasset must expose common field: " + field); + } + } + + @Test + void commonBinaryFields_useConsistentScalarTypes() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + for (final String field : COMMON_BINARY_FIELDS) { + final GraphQLFieldDefinition binaryDef = dotBinary.getFieldDefinition(field); + final GraphQLFieldDefinition fileAssetDef = dotFileasset.getFieldDefinition(field); + assertEquals(binaryDef.getType(), fileAssetDef.getType(), + "Scalar type for common field '" + field + + "' must be consistent across DotBinary and DotFileasset"); + } + } + + @Test + void commonBinaryFields_useExpectedScalarTypes() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + assertScalar(dotBinary, "name", Scalars.GraphQLString); + assertScalar(dotBinary, "title", Scalars.GraphQLString); + assertScalar(dotBinary, "size", ExtendedScalars.GraphQLLong); + assertScalar(dotBinary, "mime", Scalars.GraphQLString); + assertScalar(dotBinary, "versionPath", Scalars.GraphQLString); + assertScalar(dotBinary, "idPath", Scalars.GraphQLString); + assertScalar(dotBinary, "path", Scalars.GraphQLString); + assertScalar(dotBinary, "sha256", Scalars.GraphQLString); + assertScalar(dotBinary, "isImage", Scalars.GraphQLBoolean); + assertScalar(dotBinary, "width", ExtendedScalars.GraphQLLong); + assertScalar(dotBinary, "height", ExtendedScalars.GraphQLLong); + assertScalar(dotBinary, "modDate", ExtendedScalars.GraphQLLong); + } + + @Test + void dotBinary_keepsTypeSpecificField_focalPoint() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + assertNotNull(dotBinary.getFieldDefinition("focalPoint"), + "focalPoint remains a DotBinary-specific field"); + } + + @Test + void dotFileasset_keepsBackwardCompatibleFields() { + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + // These were the existing fields before #34540; they must still be present so + // existing queries keep working. + assertNotNull(dotFileasset.getFieldDefinition("fileName")); + assertNotNull(dotFileasset.getFieldDefinition("description")); + assertNotNull(dotFileasset.getFieldDefinition("fileAsset")); + assertNotNull(dotFileasset.getFieldDefinition("metaData")); + assertNotNull(dotFileasset.getFieldDefinition("showOnMenu")); + assertNotNull(dotFileasset.getFieldDefinition("sortOrder")); + } + + private static void assertScalar(final GraphQLObjectType type, final String field, + final GraphQLOutputType expected) { + final GraphQLFieldDefinition def = type.getFieldDefinition(field); + assertNotNull(def, "Missing field: " + field); + assertEquals(expected, def.getType(), + "Field '" + field + "' on " + type.getName() + " has unexpected scalar type"); + } +}