diff --git a/pkg/java/README.md b/pkg/java/README.md index 8aac09c2..db032e14 100644 --- a/pkg/java/README.md +++ b/pkg/java/README.md @@ -61,7 +61,32 @@ var jsonMod = new FgaModTransformer(modFileContents).transform(); ### Transform set of Modules To Model -Not yet implemented, but the [enchancement is in our issue backlog](https://github.com/openfga/language/issues/279). +```java +import dev.openfga.language.ModuleFile; +import dev.openfga.language.ModulesToModelTransformer; + +... + +var modules = List.of( + new ModuleFile("core.fga", """ +module core + +type user +"""), + new ModuleFile("wiki.fga", """ +module wiki + +type page + relations + define owner: [user] +""")); + +// Merge the modules into a single AuthorizationModel +var model = ModulesToModelTransformer.transformToModel(modules, "1.2"); + +// Or get the merged model as a JSON string +var json = ModulesToModelTransformer.transform(modules, "1.2"); +``` ### Validation diff --git a/pkg/java/src/main/java/dev/openfga/language/DslToJsonTransformer.java b/pkg/java/src/main/java/dev/openfga/language/DslToJsonTransformer.java index cb82b7b3..b17264de 100644 --- a/pkg/java/src/main/java/dev/openfga/language/DslToJsonTransformer.java +++ b/pkg/java/src/main/java/dev/openfga/language/DslToJsonTransformer.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.regex.Pattern; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; @@ -42,6 +43,17 @@ private String cleanLine(String line) { } public Result parseDsl(String dsl) { + var listener = walk(dsl); + return new Result(listener.getAuthorizationModel(), listener.errorListener.getErrors()); + } + + public ModularResult parseModularDsl(String dsl) { + var listener = walk(dsl); + return new ModularResult( + listener.getAuthorizationModel(), listener.errorListener.getErrors(), listener.getTypeDefExtensions()); + } + + private OpenFgaDslListener walk(String dsl) { var cleanedDsl = Arrays.stream(dsl.split("\n")).map(this::cleanLine).collect(joining("\n")); var antlrStream = CharStreams.fromString(cleanedDsl); @@ -57,9 +69,10 @@ public Result parseDsl(String dsl) { parser.addErrorListener(errorListener); var listener = new OpenFgaDslListener(parser); + listener.errorListener = errorListener; new ParseTreeWalker().walk(listener, parser.main()); - return new Result(listener.getAuthorizationModel(), errorListener.getErrors()); + return listener; } public static final class Result { @@ -87,4 +100,33 @@ public boolean IsFailure() { return !IsSuccess(); } } + + public static final class ModularResult { + private final AuthorizationModel authorizationModel; + private final List errors; + private final Set typeDefExtensions; + + public ModularResult( + AuthorizationModel authorizationModel, List errors, Set typeDefExtensions) { + this.authorizationModel = authorizationModel; + this.errors = errors; + this.typeDefExtensions = typeDefExtensions; + } + + public AuthorizationModel getAuthorizationModel() { + return authorizationModel; + } + + public List getErrors() { + return errors; + } + + public Set getTypeDefExtensions() { + return typeDefExtensions; + } + + public boolean isFailure() { + return !errors.isEmpty(); + } + } } diff --git a/pkg/java/src/main/java/dev/openfga/language/JsonToDslTransformer.java b/pkg/java/src/main/java/dev/openfga/language/JsonToDslTransformer.java index 9740c105..3464cba1 100644 --- a/pkg/java/src/main/java/dev/openfga/language/JsonToDslTransformer.java +++ b/pkg/java/src/main/java/dev/openfga/language/JsonToDslTransformer.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import dev.openfga.language.errors.UnsupportedDSLNestingException; +import dev.openfga.language.errors.UnsupportedModularModulesException; import dev.openfga.sdk.api.model.*; import java.util.*; import java.util.function.Function; @@ -14,10 +15,15 @@ public class JsonToDslTransformer { private static final String EOL = System.lineSeparator(); + private boolean includeSourceInformation = false; + public String transform(String json) throws JsonProcessingException { + return transform(json, false); + } + public String transform(String json, boolean includeSourceInformation) throws JsonProcessingException { + this.includeSourceInformation = includeSourceInformation; var model = JSON.parse(json, AuthorizationModel.class); - return transformJSONToDSL(model); } @@ -27,24 +33,41 @@ private String transformJSONToDSL(AuthorizationModel model) { schemaVersion = model.getSchemaVersion(); } + var modular = isModularModel(model); + var formattedTypeDefinitions = new StringBuilder(); if (model != null && model.getTypeDefinitions() != null) { - for (var typeDefinition : model.getTypeDefinitions()) { - formattedTypeDefinitions.append(formatType(typeDefinition)).append(EOL); + var typeDefinitions = new ArrayList<>(model.getTypeDefinitions()); + if (modular) { + typeDefinitions.sort((a, b) -> sortByModule( + a.getType(), + b.getType(), + module(a.getMetadata()), + module(b.getMetadata()), + file(a.getMetadata()), + file(b.getMetadata()))); + } + for (var typeDefinition : typeDefinitions) { + formattedTypeDefinitions + .append(formatType(typeDefinition, modular)) + .append(EOL); } } - var fomattedConditions = formatConditions(model); + var fomattedConditions = formatConditions(model, modular); return "model" + EOL + " schema " + schemaVersion + EOL + formattedTypeDefinitions + fomattedConditions; } - private String formatType(TypeDefinition typeDef) { + private String formatType(TypeDefinition typeDef, boolean modular) { var typeName = typeDef.getType(); - var formatedTypeBuilder = new StringBuilder(EOL).append("type ").append(typeName); + var metadata = typeDef.getMetadata(); + var formatedTypeBuilder = new StringBuilder(EOL) + .append("type ") + .append(typeName) + .append(constructSourceComment(module(metadata), file(metadata), "")); var relations = requireNonNullElseGet(typeDef.getRelations(), HashMap::new); - var metadata = typeDef.getMetadata(); var emptyMetadataRelation = new HashMap(); var metadataRelations = metadata != null ? metadata.getRelations() : emptyMetadataRelation; if (metadataRelations == null) { @@ -53,12 +76,15 @@ private String formatType(TypeDefinition typeDef) { if (!relations.isEmpty()) { formatedTypeBuilder.append(EOL).append(" relations"); - for (var relationEntry : relations.entrySet()) { - var relationName = relationEntry.getKey(); - var relationDefinition = relationEntry.getValue(); - metadataRelations.get(relationName); - var formattedRelationString = - formatRelation(typeName, relationName, relationDefinition, metadataRelations.get(relationName)); + var relationNames = new ArrayList<>(relations.keySet()); + if (modular) { + var meta = metadataRelations; + relationNames.sort((a, b) -> sortByModule( + a, b, module(meta.get(a)), module(meta.get(b)), file(meta.get(a)), file(meta.get(b)))); + } + for (var relationName : relationNames) { + var formattedRelationString = formatRelation( + typeName, relationName, relations.get(relationName), metadataRelations.get(relationName)); formatedTypeBuilder.append(EOL).append(formattedRelationString); } } @@ -87,7 +113,9 @@ private String formatRelation( formatter.format(typeName, relationName, relationDefinition, typeRestrictions, validator); if (validator.occurences() == 0 || (validator.occurences() == 1 && validator.isFirstPosition(relationDefinition))) { - return " define " + relationName + ": " + formattedRelation; + var sourceComment = + constructSourceComment(module(relationMetadata), file(relationMetadata), " extended by:"); + return " define " + relationName + ": " + formattedRelation + sourceComment; } throw new UnsupportedDSLNestingException(typeName, relationName); @@ -308,21 +336,28 @@ private CharSequence formatTupleToUserset(Userset relationDefinition) { return new StringBuilder(computedUserset).append(" from ").append(tupleset); } - private CharSequence formatConditions(AuthorizationModel model) { + private CharSequence formatConditions(AuthorizationModel model, boolean modular) { var conditions = model.getConditions(); if (conditions == null || conditions.isEmpty()) { return ""; } - var formattedConditions = new StringBuilder(); - var sortedCondition = new TreeMap<>(conditions); - - for (var conditionEntry : sortedCondition.entrySet()) { - var conditionName = conditionEntry.getKey(); - var conditionDef = conditionEntry.getValue(); + var conditionNames = new ArrayList<>(conditions.keySet()); + if (modular) { + conditionNames.sort((a, b) -> sortByModule( + a, + b, + module(conditions.get(a).getMetadata()), + module(conditions.get(b).getMetadata()), + file(conditions.get(a).getMetadata()), + file(conditions.get(b).getMetadata()))); + } else { + conditionNames.sort(null); + } - var formattedCondition = formatCondition(conditionName, conditionDef); - formattedConditions.append(EOL).append(formattedCondition); + var formattedConditions = new StringBuilder(); + for (var conditionName : conditionNames) { + formattedConditions.append(EOL).append(formatCondition(conditionName, conditions.get(conditionName))); } return formattedConditions; @@ -344,6 +379,8 @@ private CharSequence formatCondition(String conditionName, Condition conditionDe .append(conditionDef.getExpression()) .append(EOL) .append('}') + .append(constructSourceComment( + module(conditionDef.getMetadata()), file(conditionDef.getMetadata()), "")) .append(EOL); } @@ -385,4 +422,114 @@ CharSequence format( List typeRestrictions, DirectAssignmentValidator validator); } + + private CharSequence constructSourceComment(String module, String file, String leadingString) { + if (!includeSourceInformation || (isBlank(module) && isBlank(file))) { + return ""; + } + return " #" + leadingString + " module: " + nullToEmpty(module) + ", file: " + nullToEmpty(file); + } + + private static boolean isModularModel(AuthorizationModel model) { + if (model == null || model.getTypeDefinitions() == null) { + return false; + } + return model.getTypeDefinitions().stream().anyMatch(typeDef -> !isBlank(module(typeDef.getMetadata()))); + } + + /** Returns the alphabetically sorted set of modules declared in a modular model, or empty if not modular. */ + public static List getModulesFromJSON(AuthorizationModel model) { + var schemaVersion = model != null && model.getSchemaVersion() != null ? model.getSchemaVersion() : "1.1"; + if (!schemaVersion.equals("1.2")) { + throw new UnsupportedModularModulesException(schemaVersion); + } + + if (!isModularModel(model)) { + return List.of(); + } + + var modules = new TreeSet(); + for (var typeDef : model.getTypeDefinitions()) { + var metadata = typeDef.getMetadata(); + if (metadata == null) { + continue; + } + if (!isBlank(metadata.getModule())) { + modules.add(metadata.getModule()); + } + if (metadata.getRelations() != null) { + metadata.getRelations().values().stream() + .map(RelationMetadata::getModule) + .filter(m -> !isBlank(m)) + .forEach(modules::add); + } + } + + if (model.getConditions() != null) { + model.getConditions().values().stream() + .map(condition -> module(condition.getMetadata())) + .filter(m -> !isBlank(m)) + .forEach(modules::add); + } + + return new ArrayList<>(modules); + } + + private static int sortByModule( + String aName, String bName, String aModule, String bModule, String aFile, String bFile) { + if (isBlank(aModule) && isBlank(bModule)) { + return aName.compareTo(bName); + } + if (isBlank(aModule)) { + return -1; + } + if (isBlank(bModule)) { + return 1; + } + if (!aModule.equals(bModule)) { + return aModule.compareTo(bModule); + } + if (!nullToEmpty(aFile).equals(nullToEmpty(bFile))) { + return nullToEmpty(aFile).compareTo(nullToEmpty(bFile)); + } + return aName.compareTo(bName); + } + + private static String module(Metadata metadata) { + return metadata == null ? null : metadata.getModule(); + } + + private static String module(RelationMetadata metadata) { + return metadata == null ? null : metadata.getModule(); + } + + private static String module(ConditionMetadata metadata) { + return metadata == null ? null : metadata.getModule(); + } + + private static String file(Metadata metadata) { + return metadata == null || metadata.getSourceInfo() == null + ? null + : metadata.getSourceInfo().getFile(); + } + + private static String file(RelationMetadata metadata) { + return metadata == null || metadata.getSourceInfo() == null + ? null + : metadata.getSourceInfo().getFile(); + } + + private static String file(ConditionMetadata metadata) { + return metadata == null || metadata.getSourceInfo() == null + ? null + : metadata.getSourceInfo().getFile(); + } + + private static boolean isBlank(String value) { + return value == null || value.isEmpty(); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } } diff --git a/pkg/java/src/main/java/dev/openfga/language/ModuleDsl.java b/pkg/java/src/main/java/dev/openfga/language/ModuleDsl.java new file mode 100644 index 00000000..e89d26d6 --- /dev/null +++ b/pkg/java/src/main/java/dev/openfga/language/ModuleDsl.java @@ -0,0 +1,50 @@ +package dev.openfga.language; + +import dev.openfga.language.errors.StartEnd; + +/** Resolves line and column positions of symbols within a single module's DSL, for error reporting. */ +final class ModuleDsl { + + private final String[] lines; + + ModuleDsl(String contents) { + this.lines = contents.split("\n", -1); + } + + int getConditionLineNumber(String conditionName) { + return findLine("condition " + conditionName); + } + + int getTypeLineNumber(String typeName) { + return findLine("type " + typeName); + } + + int getExtendedTypeLineNumber(String typeName) { + return findLine("extend type " + typeName); + } + + int getRelationLineNumber(String relationName) { + return findLine("define " + relationName); + } + + private int findLine(String prefix) { + for (int i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith(prefix)) { + return i; + } + } + return -1; + } + + /** Builds the line and column span of {@code symbol} on {@code lineIndex}, both zero based. */ + StartEnd[] resolve(int lineIndex, String symbol) { + if (lines.length == 0 || lineIndex == -1) { + return new StartEnd[] {new StartEnd(0, 0), new StartEnd(0, 0)}; + } + + var wordIdx = Math.max(lines[lineIndex].indexOf(symbol), 0); + return new StartEnd[] { + new StartEnd(lineIndex, lineIndex), new StartEnd(wordIdx, wordIdx + symbol.length()), + }; + } +} diff --git a/pkg/java/src/main/java/dev/openfga/language/ModuleFile.java b/pkg/java/src/main/java/dev/openfga/language/ModuleFile.java new file mode 100644 index 00000000..46d972c5 --- /dev/null +++ b/pkg/java/src/main/java/dev/openfga/language/ModuleFile.java @@ -0,0 +1,20 @@ +package dev.openfga.language; + +/** A single file that makes up a modular model, identified by its name and DSL contents. */ +public final class ModuleFile { + private final String name; + private final String contents; + + public ModuleFile(String name, String contents) { + this.name = name; + this.contents = contents; + } + + public String getName() { + return name; + } + + public String getContents() { + return contents; + } +} diff --git a/pkg/java/src/main/java/dev/openfga/language/ModulesToModelTransformer.java b/pkg/java/src/main/java/dev/openfga/language/ModulesToModelTransformer.java new file mode 100644 index 00000000..c9b792bd --- /dev/null +++ b/pkg/java/src/main/java/dev/openfga/language/ModulesToModelTransformer.java @@ -0,0 +1,186 @@ +package dev.openfga.language; + +import com.fasterxml.jackson.core.JsonProcessingException; +import dev.openfga.language.errors.ErrorProperties; +import dev.openfga.language.errors.ModuleTransformationError; +import dev.openfga.language.errors.ModuleTransformationSingleError; +import dev.openfga.language.errors.ParsingError; +import dev.openfga.language.errors.StartEnd; +import dev.openfga.sdk.api.model.AuthorizationModel; +import dev.openfga.sdk.api.model.Condition; +import dev.openfga.sdk.api.model.ConditionMetadata; +import dev.openfga.sdk.api.model.Metadata; +import dev.openfga.sdk.api.model.SourceInfo; +import dev.openfga.sdk.api.model.TypeDefinition; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Merges a set of module files into a single {@link AuthorizationModel}, mirroring the OpenFGA modular DSL. */ +public class ModulesToModelTransformer { + + private final List errors = new ArrayList<>(); + private final List moduleFiles; + private final String schemaVersion; + + public ModulesToModelTransformer(List moduleFiles, String schemaVersion) { + this.moduleFiles = moduleFiles; + this.schemaVersion = schemaVersion; + } + + public static String transform(List moduleFiles, String schemaVersion) + throws JsonProcessingException, ModuleTransformationError { + return JSON.stringify(transformToModel(moduleFiles, schemaVersion)); + } + + public static AuthorizationModel transformToModel(List moduleFiles, String schemaVersion) + throws ModuleTransformationError { + return new ModulesToModelTransformer(moduleFiles, schemaVersion).transform(); + } + + public AuthorizationModel transform() throws ModuleTransformationError { + var transformer = new DslToJsonTransformer(); + + var typeDefs = new ArrayList(); + var types = new HashSet(); + var conditions = new LinkedHashMap(); + var extendedTypeDefs = new LinkedHashMap>(); + var dslByFile = new HashMap(); + + for (var moduleFile : moduleFiles) { + var filename = moduleFile.getName(); + var dsl = new ModuleDsl(moduleFile.getContents()); + dslByFile.put(filename, dsl); + + var result = transformer.parseModularDsl(moduleFile.getContents()); + if (result.isFailure()) { + result.getErrors().forEach(error -> { + error.setFile(filename); + errors.add(error); + }); + continue; + } + + var extensions = result.getTypeDefExtensions(); + for (var typeDef : result.getAuthorizationModel().getTypeDefinitions()) { + if (extensions.contains(typeDef.getType())) { + extendedTypeDefs + .computeIfAbsent(filename, k -> new ArrayList<>()) + .add(typeDef); + continue; + } + + if (types.contains(typeDef.getType())) { + addError( + "duplicate type definition " + typeDef.getType(), + dsl, + dsl.getTypeLineNumber(typeDef.getType()), + typeDef.getType(), + filename); + continue; + } + + if (typeDef.getMetadata() == null) { + addError("file is not a module", new StartEnd(0, 0), new StartEnd(0, 0), filename); + continue; + } + + typeDef.getMetadata().setSourceInfo(new SourceInfo()._file(filename)); + types.add(typeDef.getType()); + typeDefs.add(typeDef); + } + + result.getAuthorizationModel().getConditions().forEach((name, condition) -> { + if (conditions.containsKey(name)) { + addError("duplicate condition " + name, dsl, dsl.getConditionLineNumber(name), name, filename); + return; + } + + var metadata = condition.getMetadata() != null ? condition.getMetadata() : new ConditionMetadata(); + condition.setMetadata(metadata.sourceInfo(new SourceInfo()._file(filename))); + conditions.put(name, condition); + }); + } + + mergeExtensions(typeDefs, extendedTypeDefs, dslByFile); + + if (!errors.isEmpty()) { + throw new ModuleTransformationError(errors); + } + + return new AuthorizationModel() + .schemaVersion(schemaVersion) + .typeDefinitions(typeDefs) + .conditions(conditions); + } + + private void mergeExtensions( + List typeDefs, + Map> extendedTypeDefs, + Map dslByFile) { + extendedTypeDefs.forEach((filename, extensions) -> { + var dsl = dslByFile.get(filename); + + for (var typeDef : extensions) { + var original = typeDefs.stream() + .filter(t -> t.getType().equals(typeDef.getType())) + .findFirst() + .orElse(null); + + if (original == null) { + addError( + "extended type " + typeDef.getType() + " does not exist", + dsl, + dsl.getExtendedTypeLineNumber(typeDef.getType()), + typeDef.getType(), + filename); + continue; + } + + if (original.getMetadata() == null) { + original.setMetadata(new Metadata()); + } + + var existingRelations = original.getRelations(); + if (existingRelations == null || existingRelations.isEmpty()) { + original.setRelations(typeDef.getRelations()); + var relations = typeDef.getMetadata().getRelations(); + original.getMetadata().setRelations(relations); + if (relations != null) { + relations.values().forEach(meta -> meta.setSourceInfo(new SourceInfo()._file(filename))); + } + continue; + } + + typeDef.getRelations().forEach((name, relation) -> { + if (existingRelations.containsKey(name)) { + addError( + "relation " + name + " already exists on type " + typeDef.getType(), + dsl, + dsl.getRelationLineNumber(name), + name, + filename); + return; + } + + var relationMeta = typeDef.getMetadata().getRelations().get(name); + relationMeta.setSourceInfo(new SourceInfo()._file(filename)); + original.putRelationsItem(name, relation); + original.getMetadata().putRelationsItem(name, relationMeta); + }); + } + }); + } + + private void addError(String message, ModuleDsl dsl, int lineIndex, String symbol, String file) { + var position = dsl.resolve(lineIndex, symbol); + addError(message, position[0], position[1], file); + } + + private void addError(String message, StartEnd line, StartEnd column, String file) { + errors.add(new ModuleTransformationSingleError(new ErrorProperties(line, column, message), file)); + } +} diff --git a/pkg/java/src/main/java/dev/openfga/language/OpenFgaDslListener.java b/pkg/java/src/main/java/dev/openfga/language/OpenFgaDslListener.java index 5d7b8c66..021b9f1b 100644 --- a/pkg/java/src/main/java/dev/openfga/language/OpenFgaDslListener.java +++ b/pkg/java/src/main/java/dev/openfga/language/OpenFgaDslListener.java @@ -12,10 +12,12 @@ public class OpenFgaDslListener extends OpenFGAParserBaseListener { private final AuthorizationModel authorizationModel = new AuthorizationModel(); private final OpenFGAParser parser; + OpenFgaDslErrorListener errorListener; private TypeDefinition currentTypeDef = null; private Relation currentRelation = null; private Condition currentCondition = null; private boolean isModularModel = false; + private String moduleName = null; private HashMap typeDefExtensions = new HashMap(); private Deque rewriteStack = null; @@ -28,6 +30,10 @@ public AuthorizationModel getAuthorizationModel() { return authorizationModel; } + public Set getTypeDefExtensions() { + return typeDefExtensions.keySet(); + } + private Userset parseExpression(List rewrites, String operator) { if (rewrites.isEmpty()) { @@ -67,6 +73,9 @@ public void exitModelHeader(OpenFGAParser.ModelHeaderContext ctx) { @Override public void exitModuleHeader(OpenFGAParser.ModuleHeaderContext ctx) { this.isModularModel = true; + if (ctx.moduleName != null) { + this.moduleName = ctx.moduleName.getText(); + } } @Override @@ -88,6 +97,10 @@ public void enterTypeDef(OpenFGAParser.TypeDefContext ctx) { .type(ctx.typeName.getText()) .relations(new HashMap<>()) .metadata(new Metadata().relations(new HashMap<>())); + + if (isModularModel) { + currentTypeDef.getMetadata().setModule(moduleName); + } } @Override @@ -108,6 +121,10 @@ public void enterCondition(OpenFGAParser.ConditionContext ctx) { } currentCondition = new Condition().name(conditionName).expression("").parameters(new HashMap<>()); + + if (isModularModel) { + currentCondition.setMetadata(new ConditionMetadata().module(moduleName)); + } } @Override @@ -170,7 +187,10 @@ public void exitTypeDef(OpenFGAParser.TypeDefContext ctx) { return; } - if (currentTypeDef.getMetadata() != null + // In a non-modular model a type without relations carries no metadata; in a modular model the metadata is + // retained because it holds the module and source information, with an empty relations map. + if (!isModularModel + && currentTypeDef.getMetadata() != null && currentTypeDef.getMetadata().getRelations() != null && currentTypeDef.getMetadata().getRelations().isEmpty()) { currentTypeDef.setMetadata(null); @@ -219,10 +239,16 @@ public void exitRelationDeclaration(OpenFGAParser.RelationDeclarationContext ctx currentTypeDef.getRelations().put(relationName, relationDef); var directlyRelatedUserTypes = currentRelation.getTypeInfo().getDirectlyRelatedUserTypes(); - currentTypeDef - .getMetadata() - .getRelations() - .put(relationName, new RelationMetadata().directlyRelatedUserTypes(directlyRelatedUserTypes)); + var relationMetadata = new RelationMetadata().directlyRelatedUserTypes(directlyRelatedUserTypes); + + var parent = ctx.getParent(); + var isExtension = parent instanceof OpenFGAParser.TypeDefContext + && ((OpenFGAParser.TypeDefContext) parent).EXTEND() != null; + if (isModularModel && isExtension) { + relationMetadata.setModule(moduleName); + } + + currentTypeDef.getMetadata().getRelations().put(relationName, relationMetadata); } currentRelation = null; diff --git a/pkg/java/src/main/java/dev/openfga/language/errors/ModuleTransformationError.java b/pkg/java/src/main/java/dev/openfga/language/errors/ModuleTransformationError.java new file mode 100644 index 00000000..bdfa29a1 --- /dev/null +++ b/pkg/java/src/main/java/dev/openfga/language/errors/ModuleTransformationError.java @@ -0,0 +1,17 @@ +package dev.openfga.language.errors; + +import java.util.List; + +public class ModuleTransformationError extends Exception { + + private final List errors; + + public ModuleTransformationError(List errors) { + super(Errors.messagesFromErrors(errors)); + this.errors = errors; + } + + public List getErrors() { + return errors; + } +} diff --git a/pkg/java/src/main/java/dev/openfga/language/errors/ModuleTransformationSingleError.java b/pkg/java/src/main/java/dev/openfga/language/errors/ModuleTransformationSingleError.java new file mode 100644 index 00000000..bd7d9cb1 --- /dev/null +++ b/pkg/java/src/main/java/dev/openfga/language/errors/ModuleTransformationSingleError.java @@ -0,0 +1,15 @@ +package dev.openfga.language.errors; + +public class ModuleTransformationSingleError extends ParsingError { + // Needed for Jackson deserialization + public ModuleTransformationSingleError() {} + + public ModuleTransformationSingleError(ErrorProperties properties) { + super("transformation", properties); + } + + public ModuleTransformationSingleError(ErrorProperties properties, String file) { + super("transformation", properties); + setFile(file); + } +} diff --git a/pkg/java/src/main/java/dev/openfga/language/errors/ParsingError.java b/pkg/java/src/main/java/dev/openfga/language/errors/ParsingError.java index 47ed8028..bbc4669d 100644 --- a/pkg/java/src/main/java/dev/openfga/language/errors/ParsingError.java +++ b/pkg/java/src/main/java/dev/openfga/language/errors/ParsingError.java @@ -1,5 +1,7 @@ package dev.openfga.language.errors; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Objects; public abstract class ParsingError extends SimpleError { @@ -10,6 +12,10 @@ public abstract class ParsingError extends SimpleError { private String fullMessage; + @JsonProperty("file") + @JsonInclude(JsonInclude.Include.NON_NULL) + private String file; + public ParsingError() {} public ParsingError(String type, ErrorProperties properties) { @@ -57,6 +63,14 @@ public void setFullMessage(String fullMessage) { this.fullMessage = fullMessage; } + public String getFile() { + return file; + } + + public void setFile(String file) { + this.file = file; + } + public String toString() { return Objects.requireNonNullElseGet(fullMessage, this::getMessage); } diff --git a/pkg/java/src/main/java/dev/openfga/language/errors/UnsupportedModularModulesException.java b/pkg/java/src/main/java/dev/openfga/language/errors/UnsupportedModularModulesException.java new file mode 100644 index 00000000..d761b094 --- /dev/null +++ b/pkg/java/src/main/java/dev/openfga/language/errors/UnsupportedModularModulesException.java @@ -0,0 +1,7 @@ +package dev.openfga.language.errors; + +public class UnsupportedModularModulesException extends RuntimeException { + public UnsupportedModularModulesException(String schemaVersion) { + super("model schema version " + schemaVersion + " does not support modules"); + } +} diff --git a/pkg/java/src/main/java/dev/openfga/language/utils/ModelUtils.java b/pkg/java/src/main/java/dev/openfga/language/utils/ModelUtils.java index b89e8b51..909c7dfb 100644 --- a/pkg/java/src/main/java/dev/openfga/language/utils/ModelUtils.java +++ b/pkg/java/src/main/java/dev/openfga/language/utils/ModelUtils.java @@ -1,5 +1,6 @@ package dev.openfga.language.utils; +import dev.openfga.sdk.api.model.AuthorizationModel; import dev.openfga.sdk.api.model.Metadata; import dev.openfga.sdk.api.model.RelationMetadata; import dev.openfga.sdk.api.model.TypeDefinition; @@ -72,4 +73,50 @@ public static boolean isRelationAssignable(Userset relDef) { // ComputedUserset and TupleToUserset are not assignable return false; } + + /** + * isModelModular returns true if the model is modular: schema version 1.2 with at least one type or relation + * that declares a module in its metadata. + * + * @param model An AuthorizationModel object. + * @return Whether the model is modular. + * @throws IllegalArgumentException If the model's schema version is not recognized. + */ + public static boolean isModelModular(AuthorizationModel model) { + var schemaVersion = model != null && model.getSchemaVersion() != null ? model.getSchemaVersion() : "1.1"; + switch (schemaVersion) { + case "1.0": + case "1.1": + return false; + case "1.2": + break; + default: + throw new IllegalArgumentException("Unsupported schema version: " + schemaVersion); + } + + if (model.getTypeDefinitions() == null) { + return false; + } + + for (var typeDef : model.getTypeDefinitions()) { + var metadata = typeDef.getMetadata(); + if (metadata == null) { + continue; + } + if (isNotBlank(metadata.getModule())) { + return true; + } + if (metadata.getRelations() != null + && metadata.getRelations().values().stream() + .anyMatch(relation -> isNotBlank(relation.getModule()))) { + return true; + } + } + + return false; + } + + private static boolean isNotBlank(String value) { + return value != null && !value.isEmpty(); + } } diff --git a/pkg/java/src/test/java/dev/openfga/language/JsonToDslShould.java b/pkg/java/src/test/java/dev/openfga/language/JsonToDslShould.java index 1ade2e21..9cceee24 100644 --- a/pkg/java/src/test/java/dev/openfga/language/JsonToDslShould.java +++ b/pkg/java/src/test/java/dev/openfga/language/JsonToDslShould.java @@ -4,7 +4,9 @@ import static org.assertj.core.api.Assertions.catchThrowable; import static org.junit.jupiter.params.provider.Arguments.arguments; +import dev.openfga.language.util.ModuleTransformerTestCase; import dev.openfga.language.util.TestsData; +import dev.openfga.sdk.api.model.AuthorizationModel; import java.util.stream.Stream; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.params.ParameterizedTest; @@ -38,6 +40,27 @@ public void throwAnExceptionWhenTransformingInvalidJsonToDsl( } } + @ParameterizedTest(name = "{0}") + @MethodSource("moduleTestCases") + public void transformModularJsonToDsl(String name, ModuleTransformerTestCase testCase) throws Exception { + Assumptions.assumeFalse(testCase.isSkip()); + Assumptions.assumeTrue(testCase.getDsl() != null && testCase.getModules() != null); + + var transformer = new JsonToDslTransformer(); + assertThat(transformer.transform(testCase.getJson())).isEqualTo(testCase.getDsl()); + assertThat(transformer.transform(testCase.getJson(), true)).isEqualTo(testCase.getDslWithSourceInfo()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("moduleTestCases") + public void extractModulesFromJson(String name, ModuleTransformerTestCase testCase) throws Exception { + Assumptions.assumeFalse(testCase.isSkip()); + Assumptions.assumeTrue(testCase.getExpectedModules() != null); + + var model = JSON.parse(testCase.getJson(), AuthorizationModel.class); + assertThat(JsonToDslTransformer.getModulesFromJSON(model)).isEqualTo(testCase.getExpectedModules()); + } + private static Stream transformerTestCases() { return TestsData.VALID_TRANSFORMER_TEST_CASES.stream() .map(testCase -> @@ -49,4 +72,9 @@ private static Stream invalidJsonSyntaxTestCases() { .map(testCase -> arguments( testCase.getName(), testCase.getJson(), testCase.getErrorMessage(), testCase.isSkip())); } + + private static Stream moduleTestCases() { + return TestsData.MODULE_TRANSFORMER_TEST_CASES.stream() + .map(testCase -> arguments(testCase.getName(), testCase)); + } } diff --git a/pkg/java/src/test/java/dev/openfga/language/ModuleToModelShould.java b/pkg/java/src/test/java/dev/openfga/language/ModuleToModelShould.java new file mode 100644 index 00000000..16e9eac4 --- /dev/null +++ b/pkg/java/src/test/java/dev/openfga/language/ModuleToModelShould.java @@ -0,0 +1,95 @@ +package dev.openfga.language; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import dev.openfga.language.errors.ModuleTransformationError; +import dev.openfga.language.errors.ParsingError; +import dev.openfga.language.util.ModuleExpectedError; +import dev.openfga.language.util.ModuleTransformerTestCase; +import dev.openfga.language.util.TestsData; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class ModuleToModelShould { + + @ParameterizedTest(name = "{0}") + @MethodSource("moduleTestCases") + public void transformModulesToModel(String name, ModuleTransformerTestCase testCase) throws Exception { + Assumptions.assumeFalse(testCase.isSkip()); + Assumptions.assumeTrue(testCase.getModules() != null); + + if (testCase.getExpectedErrors() == null) { + var expected = + JSON.stringify(JSON.parse(testCase.getJson(), dev.openfga.sdk.api.model.AuthorizationModel.class)); + var actual = ModulesToModelTransformer.transform(testCase.getModules(), "1.2"); + assertThat(actual).isEqualTo(expected); + return; + } + + var thrown = catchThrowable(() -> ModulesToModelTransformer.transformToModel(testCase.getModules(), "1.2")); + + assertThat(thrown).isInstanceOf(ModuleTransformationError.class); + var error = (ModuleTransformationError) thrown; + + List actualErrors = error.getErrors(); + var expectedErrors = testCase.getExpectedErrors(); + assertThat(actualErrors).hasSameSizeAs(expectedErrors); + + var hasSyntaxError = expectedErrors.stream().anyMatch(e -> "syntax".equals(e.getType())); + if (!hasSyntaxError) { + // ANTLR emits differing syntax-error text across Java/Go/JS, so only assert the full message when + // every expected error is a transformation error. + assertThat(thrown).hasMessage(expectedMessage(expectedErrors)); + } + + for (int i = 0; i < actualErrors.size(); i++) { + var expected = expectedErrors.get(i); + var actual = actualErrors.get(i); + assertThat(actual.getFile()).isEqualTo(expected.getFile()); + if (!"syntax".equals(expected.getType())) { + assertThat(actual.getMessage()).isEqualTo(expected.getMessage()); + assertThat(actual.getLine()).isEqualTo(expected.getLine()); + assertThat(actual.getColumn()).isEqualTo(expected.getColumn()); + } + } + } + + @Test + public void allowCustomSchemaVersion() throws Exception { + var model = ModulesToModelTransformer.transformToModel( + List.of(new ModuleFile("core.fga", "module core\n type user")), "1.1"); + assertThat(model.getSchemaVersion()).isEqualTo("1.1"); + } + + private static String expectedMessage(List errors) { + var builder = new StringBuilder() + .append(errors.size()) + .append(" error") + .append(errors.size() == 1 ? "" : "s") + .append(" occurred:"); + for (var error : errors) { + var type = error.getType() != null ? error.getType() : "transformation"; + builder.append("\n\t* ") + .append(type) + .append(" error at line=") + .append(error.getLine().getStart()) + .append(", column=") + .append(error.getColumn().getStart()) + .append(": ") + .append(error.getMessage()); + } + return builder.append("\n\n").toString(); + } + + private static Stream moduleTestCases() { + return TestsData.MODULE_TRANSFORMER_TEST_CASES.stream() + .map(testCase -> arguments(testCase.getName(), testCase)); + } +} diff --git a/pkg/java/src/test/java/dev/openfga/language/util/ModuleExpectedError.java b/pkg/java/src/test/java/dev/openfga/language/util/ModuleExpectedError.java new file mode 100644 index 00000000..5b522d77 --- /dev/null +++ b/pkg/java/src/test/java/dev/openfga/language/util/ModuleExpectedError.java @@ -0,0 +1,80 @@ +package dev.openfga.language.util; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.openfga.language.errors.StartEnd; + +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ModuleExpectedError { + @JsonProperty("msg") + private String message; + + private String file; + private StartEnd line; + private StartEnd column; + private String type; + private Metadata metadata; + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class Metadata { + @JsonProperty("errorType") + private String errorType; + + public String getErrorType() { + return errorType; + } + + public void setErrorType(String errorType) { + this.errorType = errorType; + } + } + + /** True when this is a model-validation error, which the Go-equivalent module transformer does not surface. */ + public boolean isValidationError() { + return metadata != null && metadata.getErrorType() != null; + } + + public String getMessage() { + return message; + } + + public String getFile() { + return file; + } + + public StartEnd getLine() { + return line; + } + + public StartEnd getColumn() { + return column; + } + + public String getType() { + return type; + } + + public void setMessage(String message) { + this.message = message; + } + + public void setFile(String file) { + this.file = file; + } + + public void setLine(StartEnd line) { + this.line = line; + } + + public void setColumn(StartEnd column) { + this.column = column; + } + + public void setType(String type) { + this.type = type; + } + + public void setMetadata(Metadata metadata) { + this.metadata = metadata; + } +} diff --git a/pkg/java/src/test/java/dev/openfga/language/util/ModuleTransformerTestCase.java b/pkg/java/src/test/java/dev/openfga/language/util/ModuleTransformerTestCase.java new file mode 100644 index 00000000..7a28c97a --- /dev/null +++ b/pkg/java/src/test/java/dev/openfga/language/util/ModuleTransformerTestCase.java @@ -0,0 +1,66 @@ +package dev.openfga.language.util; + +import dev.openfga.language.ModuleFile; +import java.util.List; + +public final class ModuleTransformerTestCase { + private final String name; + private final List modules; + private final String json; + private final String dsl; + private final String dslWithSourceInfo; + private final List expectedModules; + private final List expectedErrors; + private final boolean skip; + + public ModuleTransformerTestCase( + String name, + List modules, + String json, + String dsl, + String dslWithSourceInfo, + List expectedModules, + List expectedErrors, + boolean skip) { + this.name = name; + this.modules = modules; + this.json = json; + this.dsl = dsl; + this.dslWithSourceInfo = dslWithSourceInfo; + this.expectedModules = expectedModules; + this.expectedErrors = expectedErrors; + this.skip = skip; + } + + public String getName() { + return name; + } + + public List getModules() { + return modules; + } + + public String getJson() { + return json; + } + + public String getDsl() { + return dsl; + } + + public String getDslWithSourceInfo() { + return dslWithSourceInfo; + } + + public List getExpectedModules() { + return expectedModules; + } + + public List getExpectedErrors() { + return expectedErrors; + } + + public boolean isSkip() { + return skip; + } +} diff --git a/pkg/java/src/test/java/dev/openfga/language/util/TestsData.java b/pkg/java/src/test/java/dev/openfga/language/util/TestsData.java index f599cb28..b1032684 100644 --- a/pkg/java/src/test/java/dev/openfga/language/util/TestsData.java +++ b/pkg/java/src/test/java/dev/openfga/language/util/TestsData.java @@ -3,13 +3,18 @@ import static java.util.Collections.unmodifiableList; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfga.language.ModuleFile; import java.io.IOException; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class TestsData { @@ -20,6 +25,7 @@ public class TestsData { "../../tests/data/json-syntax-transformer-validation-cases.yaml"; public static final String FGA_MOD_CASES_FILE = "../../tests/data/fga-mod-transformer-cases.yaml"; public static final String JSON_VALIDATION_CASES_FILE = "../../tests/data/json-validation-cases.yaml"; + public static final String TRANSFORMER_MODULE_CASES_FOLDER = "../../tests/data/transformer-module"; public static final String SKIP_FILE = "test.skip"; public static final String AUTHORIZATION_MODEL_JSON_FILE = "authorization-model.json"; public static final String AUTHORIZATION_MODEL_DSL_FILE = "authorization-model.fga"; @@ -30,6 +36,8 @@ public class TestsData { public static final List JSON_SYNTAX_TEST_CASES = loadJsonSyntaxTestCases(); public static final List FGA_MOD_TRANSFORM_TEST_CASES = loadFgaModTransformTestCases(); public static final List JSON_VALIDATION_TEST_CASES = loadJsonValidationTestCases(); + public static final List MODULE_TRANSFORMER_TEST_CASES = + loadModuleTransformerTestCases(); private static List loadValidTransformerTestCases() { var transformerCasesFolder = Paths.get(TRANSFORMER_CASES_FOLDER); @@ -96,6 +104,85 @@ private static List loadJsonSyntaxTestCases() { } } + private static List loadModuleTransformerTestCases() { + var mapper = new ObjectMapper(); + var casesFolder = Paths.get(TRANSFORMER_MODULE_CASES_FOLDER); + + List cases = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(casesFolder)) { + for (Path path : stream) { + if (!Files.isDirectory(path)) { + continue; + } + + var name = path.getFileName().toString(); + var skip = Files.exists(path.resolve(SKIP_FILE)); + + var json = readIfExists(path.resolve(AUTHORIZATION_MODEL_JSON_FILE)); + var dsl = readIfExists(path.resolve("combined.fga")); + var dslWithSourceInfo = readIfExists(path.resolve("combined-sourceinfo.fga")); + + List expectedModules = null; + var modulesJson = readIfExists(path.resolve("expected_modules.json")); + if (modulesJson != null) { + expectedModules = mapper.readValue(modulesJson, new TypeReference<>() {}); + } + + List expectedErrors = null; + var errorsJson = readIfExists(path.resolve("expected_errors.json")); + if (errorsJson != null) { + List allErrors = mapper.readValue(errorsJson, new TypeReference<>() {}); + // The Go-equivalent transformer surfaces transformation and syntax errors only; model-validation + // errors are filtered out. A case left with no errors is skipped. + expectedErrors = allErrors.stream() + .filter(error -> !error.isValidationError()) + .collect(Collectors.toList()); + if (expectedErrors.isEmpty()) { + skip = true; + } + } + + cases.add(new ModuleTransformerTestCase( + name, + loadModuleFiles(path.resolve("module")), + json, + dsl, + dslWithSourceInfo, + expectedModules, + expectedErrors, + skip)); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + return unmodifiableList(cases); + } + + private static List loadModuleFiles(Path moduleDir) throws IOException { + if (!Files.isDirectory(moduleDir)) { + return null; + } + + try (Stream files = Files.walk(moduleDir)) { + return files.filter(Files::isRegularFile) + .filter(file -> file.getFileName().toString().endsWith(".fga")) + .sorted(Comparator.comparing(file -> file.getFileName().toString())) + .map(file -> { + try { + return new ModuleFile(file.getFileName().toString(), Files.readString(file)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList()); + } + } + + private static String readIfExists(Path path) throws IOException { + return Files.exists(path) ? Files.readString(path) : null; + } + private static List loadFgaModTransformTestCases() { var fgaModCasesFile = Paths.get(FGA_MOD_CASES_FILE); try { diff --git a/pkg/java/src/test/java/dev/openfga/language/utils/ModelUtilsTest.java b/pkg/java/src/test/java/dev/openfga/language/utils/ModelUtilsTest.java index 345a0327..bc5058fd 100644 --- a/pkg/java/src/test/java/dev/openfga/language/utils/ModelUtilsTest.java +++ b/pkg/java/src/test/java/dev/openfga/language/utils/ModelUtilsTest.java @@ -157,4 +157,47 @@ public void testIsRelationAssignable_RelationHasNoAssignableKeys() { boolean result = ModelUtils.isRelationAssignable(relDef); assertFalse(result); } + + private AuthorizationModel model(String schemaVersion, TypeDefinition... typeDefs) { + return new AuthorizationModel().schemaVersion(schemaVersion).typeDefinitions(List.of(typeDefs)); + } + + @Test + public void testIsModelModular_Schema12WithTypeModule() { + var typeDef = new TypeDefinition().type("user").metadata(new Metadata().module("user_module")); + assertTrue(ModelUtils.isModelModular(model("1.2", typeDef))); + } + + @Test + public void testIsModelModular_Schema12WithRelationModule() { + var typeDef = new TypeDefinition() + .type("document") + .relations(Map.of("viewer", new Userset())) + .metadata(new Metadata().relations(Map.of("viewer", new RelationMetadata().module("viewer_module")))); + assertTrue(ModelUtils.isModelModular(model("1.2", typeDef))); + } + + @Test + public void testIsModelModular_Schema11WithTypeModuleIsNotModular() { + var typeDef = new TypeDefinition().type("user").metadata(new Metadata().module("user_module")); + assertFalse(ModelUtils.isModelModular(model("1.1", typeDef))); + } + + @Test + public void testIsModelModular_Schema12WithoutModulesIsNotModular() { + var typeDef = new TypeDefinition().type("user").relations(Map.of("viewer", new Userset())); + assertFalse(ModelUtils.isModelModular(model("1.2", typeDef))); + } + + @Test + public void testIsModelModular_Schema12WithEmptyModuleIsNotModular() { + var typeDef = new TypeDefinition().type("user").metadata(new Metadata().module("")); + assertFalse(ModelUtils.isModelModular(model("1.2", typeDef))); + } + + @Test + public void testIsModelModular_UnsupportedSchemaThrows() { + var typeDef = new TypeDefinition().type("user"); + assertThrows(IllegalArgumentException.class, () -> ModelUtils.isModelModular(model("9.9", typeDef))); + } }