diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java b/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java index 4a5701e17b..53c4443da1 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/JsonSchemaGenerator.java @@ -32,6 +32,7 @@ import static com.predic8.membrane.annot.Constants.JSON_SCHEMA_VERSION; import static com.predic8.membrane.annot.generator.kubernetes.model.SchemaFactory.*; import static com.predic8.membrane.annot.generator.util.SchemaGeneratorUtil.escapeJsonContent; +import static com.predic8.membrane.annot.model.OtherAttributesInfo.ValueType.STRING; import static javax.tools.StandardLocation.CLASS_OUTPUT; /** @@ -100,7 +101,7 @@ private void addTopLevelProperties(Model m, MainInfo main) { private void addParserDefinitions(Model m, MainInfo main) { for (ElementInfo elementInfo : main.getElements().values()) { - if (elementInfo.getAnnotation().mixed() && !elementInfo.getChildElementSpecs().isEmpty()) { + if (elementInfo.getAnnotation().mixed() && !getJsonVisibleChildElementSpecs(elementInfo).isEmpty()) { throw new ProcessingException( "@MCElement(..., mixed=true) and @MCTextContent is not compatible with @MCChildElement.", elementInfo.getElement() @@ -140,7 +141,14 @@ private static AbstractSchema tagElementId(AbstractSchema schema, ElementI private AbstractSchema createNoEnvelopeParser(Model model, MainInfo main, ElementInfo elementInfo, String parserName) { // With noEnvelope=true, there should be exactly one child element - ChildElementInfo childSpec = elementInfo.getChildElementSpecs().getFirst(); + List visibleChildElementSpecs = getJsonVisibleChildElementSpecs(elementInfo); + if (visibleChildElementSpecs.isEmpty()) { + throw new ProcessingException( + "@MCElement(noEnvelope=true) must declare at least one JSON-visible @MCChildElement.", + elementInfo.getElement() + ); + } + ChildElementInfo childSpec = visibleChildElementSpecs.getFirst(); String childName = childSpec.getPropertyName(); boolean flowParserType = shouldGenerateFlowParserType(childSpec); @@ -198,7 +206,7 @@ private AbstractSchema createCollapsedInlineParser(ElementInfo ei, String par var attrs = ei.getAis().stream().toList(); boolean hasText = ei.getTci() != null; - boolean hasChildren = !ei.getChildElementSpecs().isEmpty(); + boolean hasChildren = !getJsonVisibleChildElementSpecs(ei).isEmpty(); if (hasChildren) { throw new ProcessingException("@MCElement(collapsed=true) must not declare child elements.", ei.getElement()); @@ -240,7 +248,7 @@ private AbstractSchema createCollapsedInlineParser(ElementInfo ei, String par private SchemaObject getParserSchemaObject(ElementInfo elementInfo, String parserName) { return object(parserName) - .additionalProperties(elementInfo.isString()) + .additionalProperties(false) .description(getDescriptionContent(elementInfo)); } @@ -305,6 +313,29 @@ private void collectProperties(Model model, MainInfo main, ElementInfo elementIn processMCAttributes(elementInfo, parserSchema); collectTextContent(elementInfo, parserSchema); processMCChilds(model, main, elementInfo, parserSchema); + processMCOtherAttributes(model, main, elementInfo, parserSchema); + } + + private void processMCOtherAttributes(Model model, MainInfo main, ElementInfo elementInfo, SchemaObject parserSchema) { + var otherAttributes = elementInfo.getOai(); + if (otherAttributes == null) { + return; + } + + if (otherAttributes.getValueType() == STRING) { + parserSchema.additionalProperties(from("string")); + return; + } + + var valueElementInfo = main.getElements().get(otherAttributes.getMapValueType()); + if (valueElementInfo == null) { + parserSchema.additionalProperties(true); + return; + } + + parserSchema.additionalProperties( + ref("additionalProperties").ref(defsRefPath(valueElementInfo.getXSDTypeName(model))) + ); } private void collectTextContent(ElementInfo elementInfo, SchemaObject parserSchema) { @@ -318,7 +349,7 @@ private void collectTextContent(ElementInfo elementInfo, SchemaObject parserSche } private void processMCChilds(Model model, MainInfo main, ElementInfo parentElementInfo, AbstractSchema parentSchema) { - for (ChildElementInfo childSpec : parentElementInfo.getChildElementSpecs()) { + for (ChildElementInfo childSpec : getJsonVisibleChildElementSpecs(parentElementInfo)) { if (!childSpec.isList()) { if (parentSchema instanceof SchemaObject parentObjectSchema) { @@ -537,7 +568,7 @@ private SchemaObject createComponentsMapParser(Model m, MainInfo main, ElementIn } private boolean hasComponentChild(ElementInfo parentElementInfo, MainInfo main) { - for (ChildElementInfo childSpec : parentElementInfo.getChildElementSpecs()) { + for (ChildElementInfo childSpec : getJsonVisibleChildElementSpecs(parentElementInfo)) { var childDeclaration = getChildElementDeclarationInfo(main, childSpec); if (childDeclaration == null) continue; @@ -586,11 +617,17 @@ private boolean hasAnyConfigurableProperty(ElementInfo elementInfo, MainInfo mai .filter(attributeInfo -> !attributeInfo.excludedFromJsonSchema()) .anyMatch(attributeInfo -> !"id".equals(attributeInfo.getXMLName())) || elementInfo.getTci() != null - || !elementInfo.getChildElementSpecs().isEmpty() + || !getJsonVisibleChildElementSpecs(elementInfo).isEmpty() || elementInfo.getOai() != null || hasComponentChild(elementInfo, main); } + private static List getJsonVisibleChildElementSpecs(ElementInfo elementInfo) { + return elementInfo.getChildElementSpecs().stream() + .filter(childElementInfo -> !childElementInfo.excludedFromJsonSchema()) + .toList(); + } + private void setItemsIfArray(AbstractSchema parentSchema, AbstractSchema itemsSchema) { if (parentSchema instanceof SchemaArray schemaArray) { schemaArray.items(itemsSchema); diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java b/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java index 61b82be03a..58b20baeb2 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/kubernetes/model/SchemaObject.java @@ -19,10 +19,12 @@ import java.util.*; import static com.predic8.membrane.annot.generator.kubernetes.model.SchemaFactory.*; +import static java.lang.Boolean.FALSE; public class SchemaObject extends AbstractSchema { - private boolean additionalProperties; + private Boolean additionalProperties; + private AbstractSchema additionalPropertiesSchema; // Java Properties (@MCAttributes, @MCChildElement) protected final List> properties = new ArrayList<>(); @@ -54,7 +56,9 @@ public ObjectNode json(ObjectNode node) { if (minProperties != null) node.put("minProperties", minProperties); if (maxProperties != null) node.put("maxProperties", maxProperties); - if (!additionalProperties && isObject()) { + if (additionalPropertiesSchema != null && isObject()) { + node.set("additionalProperties", additionalPropertiesSchema.json(jnf.objectNode())); + } else if (FALSE.equals(additionalProperties) && isObject()) { node.put("additionalProperties", false); } @@ -75,6 +79,13 @@ public SchemaObject property(AbstractSchema as) { public SchemaObject additionalProperties(boolean additionalProperties) { this.additionalProperties = additionalProperties; + this.additionalPropertiesSchema = null; + return this; + } + + public SchemaObject additionalProperties(AbstractSchema additionalPropertiesSchema) { + this.additionalProperties = null; + this.additionalPropertiesSchema = additionalPropertiesSchema; return this; } @@ -192,4 +203,4 @@ public SchemaObject allOf(List> allOf) { public boolean hasProperty(String name) { return properties.stream().anyMatch(p -> name.equals(p.getName())); } -} \ No newline at end of file +} diff --git a/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java b/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java index b780b63289..1a8b8b6bc8 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java +++ b/annot/src/main/java/com/predic8/membrane/annot/model/ChildElementInfo.java @@ -93,6 +93,10 @@ public void setList(boolean list) { this.list = list; } + public boolean excludedFromJsonSchema() { + return annotation != null && annotation.excludeFromJson(); + } + @Override public String toString() { return "ChildElementInfo{" + @@ -105,4 +109,4 @@ public String toString() { ", required=" + required + '}'; } -} \ No newline at end of file +} diff --git a/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java b/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java index 06a28a2a1f..55b471d9fd 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java +++ b/annot/src/main/java/com/predic8/membrane/annot/model/OtherAttributesInfo.java @@ -54,10 +54,11 @@ public ValueType getValueType() { if (mapValueType.getQualifiedName().toString().equals("java.lang.String")) { return ValueType.STRING; } - if (mapValueType.getQualifiedName().toString().equals("java.lang.Object")) { - return ValueType.OBJECT; - } - throw new ProcessingException("Not supported: @McOtherAttributes void setAttr(Map attrs) where T is neither String nor Object."); + return ValueType.OBJECT; + } + + public TypeElement getMapValueType() { + return mapValueType; } public enum ValueType { diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java index 114a583bc4..16abc0289b 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/McYamlIntrospector.java @@ -38,7 +38,7 @@ public static boolean isSetter(Method method) { } public static boolean isStructured(Method method) { - return findAnnotation(method, MCChildElement.class) != null; + return isJsonVisibleChild(method); } public static boolean matchesJsonKey(Method method, String key) { @@ -48,7 +48,7 @@ public static boolean matchesJsonKey(Method method, String key) { } private static boolean matchesJsonChildElementKey(Method method, String key) { - return findAnnotation(method, MCChildElement.class) != null + return isJsonVisibleChild(method) && matchesPropertyName(method, key); } @@ -101,7 +101,7 @@ public static Method getSingleChildSetter(ParsingContext pc, Class clazz) private static @NotNull List getChildSetters(Class clazz) { List childSetters = stream(clazz.getMethods()) .filter(McYamlIntrospector::isSetter) - .filter(method -> findAnnotation(method, MCChildElement.class) != null) + .filter(McYamlIntrospector::isJsonVisibleChild) .toList(); if (childSetters.isEmpty()) { throw new RuntimeException("No @MCChildElement setter found in " + clazz.getName()); @@ -155,7 +155,7 @@ public static boolean hasAttributes(Class clazz) { } public static boolean hasChildren(Class clazz) { - return stream(clazz.getMethods()).anyMatch(m -> m.isAnnotationPresent(MCChildElement.class)); + return stream(clazz.getMethods()).anyMatch(McYamlIntrospector::isJsonVisibleChild); } public static Method getAnySetter(Class clazz) { @@ -169,7 +169,7 @@ public static Method getAnySetter(Class clazz) { public static Method getChildSetter(Class clazz, Class valueClass) { return stream(clazz.getMethods()) .filter(McYamlIntrospector::isSetter) - .filter(McYamlIntrospector::isStructured) + .filter(McYamlIntrospector::isJsonVisibleChild) .filter(method -> method.getParameterTypes().length == 1) .filter(method -> method.getParameterTypes()[0].isAssignableFrom(valueClass)) .reduce((a, b) -> { @@ -178,6 +178,11 @@ public static Method getChildSetter(Class clazz, Class valueClass) { .orElseThrow(() -> new RuntimeException("Could not find child setter on %s for value of type %s".formatted(clazz.getName(), valueClass.getName()))); } + private static boolean isJsonVisibleChild(Method method) { + MCChildElement annotation = findAnnotation(method, MCChildElement.class); + return annotation != null && !annotation.excludeFromJson(); + } + public static boolean isReferenceAttribute(Method setter) { if (findAnnotation(setter, MCAttribute.class) == null) return false; diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java index 346c1ab543..483842acc2 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/ParsingContext.java @@ -38,6 +38,10 @@ public ParsingContext addPath(String path) { return new ParsingContext(context, registry,grammar,topLevel, this.path + path,key); } + public ParsingContext addProperty(String property) { + return new ParsingContext(context, registry, grammar, topLevel, path + toJsonPathProperty(property), key); + } + public ParsingContext child(String childContext, String pathSegment) { return new ParsingContext(childContext, registry, grammar, topLevel, path + pathSegment, null); } @@ -84,6 +88,16 @@ public String getPath() { return path; } + private static String toJsonPathProperty(String property) { + if (property.matches("[A-Za-z_][A-Za-z0-9_]*")) { + return "." + property; + } + return "['" + property + .replace("\\", "\\\\") + .replace("'", "\\'") + + "']"; + } + @Override public @NotNull String toString() { return """ diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java index 59ef350627..6edc63abc0 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/error/LineYamlErrorRenderer.java @@ -283,31 +283,40 @@ private static int getIndentation(String line) { } private static String getParentPath(String jsonPath) { - // Handle both $.parent.child and $.parent[0] formats - int lastDot = jsonPath.lastIndexOf('.'); - int lastBracket = jsonPath.lastIndexOf('['); + return jsonPath.substring(0, findLastSegmentStart(jsonPath)); + } - if (lastBracket > lastDot) { - // Last segment is array index like [0] - return jsonPath.substring(0, lastBracket); - } else { - // Last segment is object key like .field - return jsonPath.substring(0, lastDot); + private static String getLastSegment(String jsonPath) { + int start = findLastSegmentStart(jsonPath); + String segment = jsonPath.substring(start); + + if (segment.startsWith(".")) { + return segment.substring(1); + } + + if (segment.startsWith("['") && segment.endsWith("']")) { + return segment.substring(2, segment.length() - 2) + .replace("\\'", "'") + .replace("\\\\", "\\"); } + + if (segment.startsWith("[") && segment.endsWith("]")) { + return segment.substring(1, segment.length() - 1); + } + + throw new IllegalArgumentException("Unsupported JSONPath segment: " + segment); } - private static String getLastSegment(String jsonPath) { - // Handle both $.parent.child and $.parent[0] formats - int lastDot = jsonPath.lastIndexOf('.'); + private static int findLastSegmentStart(String jsonPath) { int lastBracket = jsonPath.lastIndexOf('['); + int lastDot = jsonPath.lastIndexOf('.'); if (lastBracket > lastDot) { - // Array index like [0] - String bracket = jsonPath.substring(lastBracket); - return bracket.substring(1, bracket.length() - 1); // Extract "0" from "[0]" - } else { - // Object key like .field - return jsonPath.substring(lastDot + 1); + return lastBracket; + } + if (lastDot >= 0) { + return lastDot; } + throw new IllegalArgumentException("Cannot determine parent path of: " + jsonPath); } } diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java index 1447d3638a..4b4f424ca4 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/MethodSetter.java @@ -75,8 +75,8 @@ private Object resolveSetterValue(ParsingContext ctx, JsonNode node, String k // Structured objects if (McYamlIntrospector.isStructured(setter)) { if (beanClass != null) - return ObjectBinder.bind(ctx.updateContext(key).addPath("." + key), beanClass, node); - return ObjectBinder.bind(ctx.updateContext(key).addPath("." + key), wanted, node); + return ObjectBinder.bind(ctx.updateContext(key).addProperty(key), beanClass, node); + return ObjectBinder.bind(ctx.updateContext(key).addProperty(key), wanted, node); } return coerceScalarOrReference(ctx, node, key, wanted); @@ -103,7 +103,7 @@ Object coerceScalarOrReference(ParsingContext ctx, JsonNode node, String key, return null; Class elemType = getCollectionElementType(setter); - List list = CollectionBinder.parseListIncludingStartEvent(ctx.addPath("." + key), node, elemType); + List list = CollectionBinder.parseListIncludingStartEvent(ctx.addProperty(key), node, elemType); if (elemType != null) { for (Object o : list) { if (o == null) continue; diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java index 8b89f09611..1225eb1848 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/CollectionBinder.java @@ -80,7 +80,7 @@ private static Object parseMapToObj(ParsingContext pc, JsonNode node) { private static Object parseMapToObj(ParsingContext ctx, JsonNode node, String key) { if ("$ref".equals(key)) return REFERENCE_RESOLVER.resolveReferencedObject(ctx, node.asText(), key); - var childContext = ctx.addPath("." + key); + var childContext = ctx.addProperty(key); return ObjectBinder.bind(childContext.updateContext(key), childContext.resolveClass(key), node); } diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java index 19227c55b3..73afaa101f 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/binding/ScalarValueConverter.java @@ -22,10 +22,12 @@ import com.predic8.membrane.annot.yaml.parsing.support.SpelEvaluator; import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.util.Map; -import static com.predic8.membrane.annot.yaml.McYamlIntrospector.hasOtherAttributes; -import static com.predic8.membrane.annot.yaml.McYamlIntrospector.isReferenceAttribute; +import static com.predic8.membrane.annot.yaml.McYamlIntrospector.*; +import static com.predic8.membrane.annot.yaml.parsing.binding.ObjectBinder.bind; import static java.lang.Boolean.parseBoolean; import static java.lang.Double.parseDouble; import static java.lang.Integer.parseInt; @@ -73,7 +75,7 @@ private Object coerceTextual(ParsingContext ctx, Method setter, JsonNode node if (isNumber(wanted)) return parseNumericOrThrow(ctx, key, wanted, evaluated, node); if (wanted == Map.class && setter != null && hasOtherAttributes(setter)) - return Map.of(key, evaluated); + return Map.of(key, convertAnySetterValue(ctx, setter, node, key)); if (isBeanReference(wanted)) return referenceResolver.resolveReference(ctx, value, key, wanted); if (setter != null && isReferenceAttribute(setter)) @@ -92,12 +94,42 @@ private Object coerceNonTextual(ParsingContext ctx, Method setter, JsonNode n if (isBoolean(wanted)) return node.isBoolean() ? node.booleanValue() : parseBoolean(node.asText()); if (wanted.equals(Map.class) && setter != null && hasOtherAttributes(setter)) - return Map.of(key, node.asText()); + return Map.of(key, convertAnySetterValue(ctx, setter, node, key)); if (setter != null && isReferenceAttribute(setter)) return resolveRegistryReference(ctx, node.asText(), key); throw unsupported(wanted, key, node); } + private Object convertAnySetterValue(ParsingContext ctx, Method setter, JsonNode node, String key) { + Class valueType = getMapValueType(setter); + if (valueType == null || valueType == Object.class) { + return SCALAR_MAPPER.convertValue(node, Object.class); + } + if (valueType == String.class) { + return node.isTextual() ? evaluateSpelForString(key, node.asText()) : node.asText(); + } + return bind( + ctx.updateContext(getElementName(valueType)).addProperty(key), + valueType, + node + ); + } + + private static Class getMapValueType(Method setter) { + Type genericType = setter.getGenericParameterTypes()[0]; + if (!(genericType instanceof ParameterizedType parameterizedType)) { + return Object.class; + } + Type valueType = parameterizedType.getActualTypeArguments()[1]; + if (valueType instanceof Class clazz) { + return clazz; + } + if (valueType instanceof ParameterizedType nested && nested.getRawType() instanceof Class clazz) { + return clazz; + } + return Object.class; + } + private Object resolveRegistryReference(ParsingContext ctx, String ref, String key) { if (ctx == null || ctx.getRegistry() == null) throw new ConfigurationParsingException("Cannot resolve reference: " + ref, null, ctx == null ? null : ctx.key(key)); diff --git a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java index 96a9fc9ce4..9572f8a422 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java +++ b/annot/src/main/java/com/predic8/membrane/annot/yaml/parsing/definition/ComponentDefinitionExtractor.java @@ -49,9 +49,9 @@ public List extract(ParseSession session, ParsingContext pc, String componentRef = "#/components/" + id; if (!session.componentIds().add(componentRef)) - throw new ConfigurationParsingException("Duplicate component id '%s'. Component ids must be unique across all included files.".formatted(componentRef), null, pc.addPath("." + id)); + throw new ConfigurationParsingException("Duplicate component id '%s'. Component ids must be unique across all included files.".formatted(componentRef), null, pc.addProperty(id)); - ensureSingleKey(pc.addPath("." + id), def); + ensureSingleKey(pc.addProperty(id), def); String componentKind = def.fieldNames().next(); ObjectNode wrapped = JsonNodeFactory.instance.objectNode(); diff --git a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java index 0e1f83e9b4..db56dabcb7 100644 --- a/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java +++ b/annot/src/test/java/com/predic8/membrane/annot/YAMLParsingErrorTest.java @@ -20,6 +20,8 @@ import org.jetbrains.annotations.*; import org.junit.jupiter.api.*; +import java.util.Map; + import static com.predic8.membrane.annot.SpringConfigurationXSDGeneratingAnnotationProcessorTest.*; import static com.predic8.membrane.annot.util.CompilerHelper.*; import static org.junit.jupiter.api.Assertions.*; @@ -405,6 +407,128 @@ public class ValidatorElement { } } + @Test + void otherAttributesKeyWithDotWrongFieldRendersErrorReport() throws Exception { + var result = compileMethodMapSources(); + + try { + parseYAML(result, """ + api: + methods: + 'rpc.echo': + wrong: 1 + """); + fail(); + } catch (RuntimeException e) { + var c = getCause(e); + var pc = c.getParsingContext(); + assertEquals("$.api.methods['rpc.echo']", pc.getPath()); + assertEquals("wrong", pc.getKey()); + + String report = c.getFormattedReport(); + assertTrue(report.contains("rpc.echo")); + assertTrue(report.contains("wrong")); + } + } + + @Test + void otherAttributesMapValueUsesLocalContextForChildren() throws Exception { + var result = compileMethodMapSources(); + + var registry = parseYAML(result, """ + api: + methods: + 'rpc.echo': + params: + location: tmp.schema.json + """); + + Object api = registry.getBeans().stream() + .filter(bean -> bean.getClass().getSimpleName().equals("ApiElement")) + .findFirst() + .orElseThrow(); + + Object methodDefinitions = api.getClass().getMethod("getMethods").invoke(api); + @SuppressWarnings("unchecked") + Map methods = (Map) methodDefinitions.getClass().getMethod("getMethods").invoke(methodDefinitions); + + Object method = methods.get("rpc.echo"); + assertNotNull(method); + + Object params = method.getClass().getMethod("getParams").invoke(method); + assertNotNull(params); + assertEquals("MethodParams", params.getClass().getSimpleName()); + assertEquals("tmp.schema.json", params.getClass().getMethod("getLocation").invoke(params)); + } + + private static CompilerResult compileMethodMapSources() { + var sources = splitSources(MC_MAIN_DEMO + """ + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + @MCElement(name="api", topLevel=true, component=false) + public class ApiElement { + private MethodDefinitions methods; + + @MCChildElement + public void setMethods(MethodDefinitions methods) { this.methods = methods; } + public MethodDefinitions getMethods() { return methods; } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + import java.util.LinkedHashMap; + import java.util.Map; + + @MCElement(name="methods", component=false) + public class MethodDefinitions { + private final Map methods = new LinkedHashMap<>(); + + @MCOtherAttributes + public void setMethods(Map methods) { + this.methods.putAll(methods); + } + + public Map getMethods() { return methods; } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + + @MCElement(name="method", component=false) + public class MethodElement { + private MethodParams params; + + @MCChildElement + public void setParams(MethodParams params) { this.params = params; } + public MethodParams getParams() { return params; } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + + @MCElement(name="params", component=false) + public class GlobalParams { + @MCAttribute + public void setOther(String other) { } + } + --- + package com.predic8.membrane.demo; + import com.predic8.membrane.annot.*; + + @MCElement(name="params", component=false, id="method-params") + public class MethodParams { + private String location; + + @MCAttribute + public void setLocation(String location) { this.location = location; } + public String getLocation() { return location; } + } + """); + var result = compile(sources, false); + assertCompilerResult(true, result); + return result; + } + private static @NotNull ConfigurationParsingException getCause(RuntimeException e) { return (ConfigurationParsingException) ExceptionUtil.getRootCause(e); } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java new file mode 100644 index 0000000000..90924204f9 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/BatchRule.java @@ -0,0 +1,60 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCAttribute; +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.core.util.ConfigurationException; + +/** + * @description Controls whether JSON-RPC batch requests are allowed and how many request objects a batch may contain. + */ +@MCElement(name = "batch", component = false) +public class BatchRule { + + private boolean enabled = true; + + private Integer maxSize = 100; + + /** + * @description Enables or disables JSON-RPC batch requests. + * @default true + */ + @MCAttribute + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * @description The maximum number of request objects allowed in one JSON-RPC batch. + * @default 100 + * @example 50 + */ + @MCAttribute + public void setMaxSize(Integer maxSize) { + if (maxSize == null || maxSize < 1) { + throw new ConfigurationException("batch maxSize must be greater than 0"); + } + this.maxSize = maxSize; + } + + public Integer getMaxSize() { + return maxSize; + } + + public boolean isEnabled() { + return enabled; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java new file mode 100644 index 0000000000..118ace41f0 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCErrorValidation.java @@ -0,0 +1,28 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; + +/** + * @description + *

Configures a JSON Schema that validates JSON-RPC error objects.

+ * + *

Use either location to load an external schema or + * schema to define the schema inline.

+ */ +@MCElement(name = "error", component = false) +public class JsonRPCErrorValidation extends SchemaSetter { +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java new file mode 100644 index 0000000000..9d276b3709 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCInlineSchema.java @@ -0,0 +1,51 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.annot.MCOtherAttributes; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @description + *

Embeds a JSON Schema inline in the Membrane configuration.

+ * + *

The entries inside schema are copied verbatim into the generated + * JSON Schema document.

+ */ +@MCElement(name = "schema", component = false, id = "json-rpc-inline-schema") +public class JsonRPCInlineSchema { + + private final Map properties = new LinkedHashMap<>(); + + /** + * @description + *

Defines the raw JSON Schema keywords for the inline schema.

+ * + * @example type: object + */ + @MCOtherAttributes + public void setProperties(Map properties) { + if (properties != null) { + this.properties.putAll(properties); + } + } + + public Map getProperties() { + return properties; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java new file mode 100644 index 0000000000..d7461b67a3 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodDefinitions.java @@ -0,0 +1,52 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.annot.MCOtherAttributes; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @description + *

Maps JSON-RPC method names to their schema validation definitions.

+ * + *

In YAML, the entries are written as a map under schemaValidation.methods.

+ */ +@MCElement(name = "methods", component = false, id = "json-rpc-method-definitions") +public class JsonRPCMethodDefinitions { + + private final Map methods = new LinkedHashMap<>(); + + /** + * @description + *

Defines the per-method schema validation entries.

+ * + *

Each key must match one JSON-RPC method value exactly.

+ * + * @example "rpc.echo": { params: { location: "classpath:/json/rpc/echo-params.schema.json" } } + */ + @MCOtherAttributes + public void setMethods(Map methods) { + if (methods != null) { + this.methods.putAll(methods); + } + } + + public Map getMethods() { + return methods; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java new file mode 100644 index 0000000000..b91f9818cf --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCParamValidation.java @@ -0,0 +1,28 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; + +/** + * @description + *

Configures JSON Schema validation for the JSON-RPC params member of one method.

+ * + *

Use either location to load an external schema or schema + * to define the schema inline.

+ */ +@MCElement(name = "params", component = false, id = "json-rpc-method-params-validation") +public class JsonRPCParamValidation extends SchemaSetter { +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java new file mode 100644 index 0000000000..8dcf31de41 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptor.java @@ -0,0 +1,238 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.predic8.membrane.annot.MCChildElement; +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.core.exchange.Exchange; +import com.predic8.membrane.core.http.Response; +import com.predic8.membrane.core.interceptor.AbstractInterceptor; +import com.predic8.membrane.core.interceptor.Outcome; +import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.RequestValidationResult; +import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ResponseValidationContext; +import com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.ValidationError; +import com.predic8.membrane.core.jsonrpc.JSONRPCResponse; +import com.predic8.membrane.core.util.config.allowdeny.Rule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; + +import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON; +import static com.predic8.membrane.core.http.Response.statusCode; +import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST; +import static com.predic8.membrane.core.interceptor.Interceptor.Flow.RESPONSE; +import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE; +import static com.predic8.membrane.core.interceptor.Outcome.RETURN; +import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.BATCH; +import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.SINGLE; +import static java.util.EnumSet.of; +import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST; + +/** + * @topic 3. Security and Validation + * @description + *

Protects JSON-RPC endpoints by validating request structure, controlling batch usage, + * applying ordered allow/deny rules to method names, and optionally validating + * request parameters and responses against JSON Schema documents.

+ * + *

Method rules are evaluated in the configured order. The first matching rule decides + * whether a method is allowed or denied.

+ * + *

Schema validation is configured under schemaValidation. Per-method + * params and response schemas can use either + * location for external JSON Schema files or schema for inline + * schema definitions.

+ * + * @yaml + *

+ * - jsonRPCProtection:
+ *     batch:
+ *       enabled: true
+ *       maxSize: 50
+ *     methods:
+ *       - allow: "^rpc\\.(health|echo)$"
+ *       - deny: ".*"
+ *     schemaValidation:
+ *       error:
+ *         location: classpath:/json/rpc/error.schema.json
+ *       methods:
+ *         "rpc.echo":
+ *           params:
+ *             location: classpath:/json/rpc/echo-params.schema.json
+ *           response:
+ *             schema:
+ *               type: object
+ *               required: [message]
+ *               properties:
+ *                 message:
+ *                   type: string
+ * 
+ */ +@MCElement(name = "jsonRPCProtection") +public class JsonRPCProtectionInterceptor extends AbstractInterceptor { + + private static final Logger log = LoggerFactory.getLogger(JsonRPCProtectionInterceptor.class); + private static final ObjectMapper OM = new ObjectMapper(); + private static final String RESPONSE_VALIDATION_CONTEXT = JsonRPCProtectionInterceptor.class.getName() + ".responseValidationContext"; + + private BatchRule batchRule = new BatchRule(); + private List methods = List.of(); + private JsonRPCSchemaValidation schemaValidation = new JsonRPCSchemaValidation(); + private JsonRPCValidator validator; + + public JsonRPCProtectionInterceptor() { + name = "JSON-RPC protection"; + setAppliedFlow(of(REQUEST, RESPONSE)); + } + + @Override + public void init() { + super.init(); + validator = createValidator(); + } + + @Override + public Outcome handleRequest(Exchange exc) { + if (!exc.getRequest().isPOSTRequest()) { + return CONTINUE; + } + + if (!exc.getRequest().isJSON()) { + return rejectRequest(exc, new ValidationError( + payloadType(exc.getRequest().getBodyAsStringDecoded()), + null, + 415, + ERR_INVALID_REQUEST, + "Content-Type %s is not supported. Expected application/json.".formatted(exc.getRequest().getHeader().getContentType()) + )); + } + + RequestValidationResult validation = getValidator().validateRequest(exc.getRequest().getBodyAsStringDecoded()); + if (validation.responseValidationContext() != null) { + exc.setProperty(RESPONSE_VALIDATION_CONTEXT, validation.responseValidationContext()); + } + return rejectRequest(exc, validation.error()); + } + + @Override + public Outcome handleResponse(Exchange exc) { + if (exc.getResponse() == null || !schemaValidation.hasResponseValidation()) { + return CONTINUE; + } + + ResponseValidationContext context = exc.getProperty(RESPONSE_VALIDATION_CONTEXT, ResponseValidationContext.class); + if (context == null && schemaValidation.hasErrorValidation()) { + context = new ResponseValidationContext(payloadType(exc.getResponse().getBodyAsStringDecoded()), java.util.Map.of()); + } + + return rejectResponse(exc, getValidator().validateResponse(exc.getResponse().getBodyAsStringDecoded(), context)); + } + + private Outcome rejectRequest(Exchange exc, ValidationError error) { + if (error == null) { + return CONTINUE; + } + log.info("Rejected JSON-RPC request: {}", error.message()); + exc.setResponse(createErrorResponse(error)); + return RETURN; + } + + private Outcome rejectResponse(Exchange exc, ValidationError error) { + if (error == null) { + return CONTINUE; + } + log.info("Rejected JSON-RPC response: {}", error.message()); + exc.setResponse(createErrorResponse(error)); + return RETURN; + } + + /** + * @description Configures whether JSON-RPC batch requests are allowed and how many request objects one batch may contain. + */ + @MCChildElement(order = 0) + public void setBatch(BatchRule batchRule) { + this.batchRule = batchRule; + } + + /** + * @description + *

Configures ordered allow/deny rules for JSON-RPC method names.

+ * + *

The first matching rule decides whether the method is allowed or denied. Methods that do not match any configured rule are allowed. To switch to default-deny behavior, add a final deny rule such as deny: .*.

+ */ + @MCChildElement(order = 1) + public void setMethods(List methods) { + this.methods = methods; + } + + + + @MCChildElement(order = 4) + public void setSchemaValidation(JsonRPCSchemaValidation schemaValidation) { + this.schemaValidation = schemaValidation == null ? new JsonRPCSchemaValidation() : schemaValidation; + } + + public JsonRPCSchemaValidation getSchemaValidation() { + return schemaValidation; + } + + public BatchRule getBatch() { + return batchRule; + } + + public List getMethods() { + return methods; + } + + private JsonRPCValidator getValidator() { + if (validator == null) { + validator = createValidator(); + } + return validator; + } + + private JsonRPCValidator createValidator() { + schemaValidation.init(router.getResolverMap(), router.getConfiguration().getUriFactory(), getBeanBaseLocation()); + return new JsonRPCValidator(batchRule, methods, schemaValidation); + } + + private Response createErrorResponse(ValidationError error) { + try { + if (error.payloadType() == BATCH) { + return statusCode(error.httpStatus()) + .contentType(APPLICATION_JSON) + .body(OM.writeValueAsString(List.of(JSONRPCResponse.error(error.responseId(), error.code(), error.message())))) + .build(); + } + + return statusCode(error.httpStatus()) + .contentType(APPLICATION_JSON) + .body(JSONRPCResponse.error(error.responseId(), error.code(), error.message()).toJson()) + .build(); + } catch (IOException e) { + throw new RuntimeException("Could not create JSON-RPC error response", e); + } + } + + private JsonRPCValidator.PayloadType payloadType(String body) { + if (body == null) { + return SINGLE; + } + return body.trim().startsWith("[") ? BATCH : SINGLE; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java new file mode 100644 index 0000000000..1348761526 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCResponseValidation.java @@ -0,0 +1,28 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCElement; + +/** + * @description + *

Configures JSON Schema validation for successful JSON-RPC responses of one method.

+ * + *

The schema is applied to the JSON-RPC result value. Use either + * location or an inline schema.

+ */ +@MCElement(name = "response", component = false, id = "json-rpc-response-validation") +public class JsonRPCResponseValidation extends SchemaSetter { +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java new file mode 100644 index 0000000000..f0daed911b --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java @@ -0,0 +1,345 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.InputFormat; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.resource.SchemaLoader; +import com.predic8.membrane.annot.MCChildElement; +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.core.interceptor.schemavalidation.json.MembraneSchemaLoader; +import com.predic8.membrane.core.resolver.Resolver; +import com.predic8.membrane.core.util.ConfigurationException; +import com.predic8.membrane.core.util.URIFactory; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.networknt.schema.InputFormat.JSON; +import static com.networknt.schema.InputFormat.YAML; +import static com.networknt.schema.SchemaRegistry.withDefaultDialect; +import static com.networknt.schema.SpecificationVersion.DRAFT_2020_12; +import static com.predic8.membrane.core.resolver.ResolverMap.combine; + +/** + * @description + *

Configures JSON Schema validation for JSON-RPC request params, successful responses, + * and error responses.

+ * + *

Under methods, each key is matched against the exact JSON-RPC + * method value. For every method, params and + * response can define either a schema location or an + * inline schema.

+ * + *

The optional error entry validates JSON-RPC error + * objects returned by the upstream service.

+ */ +@MCElement(name = "schemaValidation", component = false) +public class JsonRPCSchemaValidation { + + private static final ObjectMapper OM = new ObjectMapper(); + + private JsonRPCErrorValidation errorValidation; + private JsonRPCMethodDefinitions methods = new JsonRPCMethodDefinitions(); + private Schema errorSchema; + private Map paramSchemas = Map.of(); + private Map responseSchemas = Map.of(); + + public JsonRPCErrorValidation getErrorValidation() { + return errorValidation; + } + + /** + * @description + *

Configures a JSON Schema for validating JSON-RPC error objects.

+ * + *

This applies to upstream responses that contain an error member + * instead of a successful result.

+ */ + @MCChildElement(order = 1) + public void setErrorValidation(JsonRPCErrorValidation errorValidation) { + this.errorValidation = errorValidation; + } + + /** + * @description + *

Configures per-method JSON Schema validation rules.

+ * + *

The keys in this map are exact JSON-RPC method names such as + * rpc.echo.

+ */ + @MCChildElement(order = 2) + public void setMethods(JsonRPCMethodDefinitions methods) { + this.methods = methods == null ? new JsonRPCMethodDefinitions() : methods; + } + + public JsonRPCMethodDefinitions getMethods() { + return methods; + } + + public void init(Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { + if (resolver == null || uriFactory == null) { + throw new ConfigurationException("Cannot initialize JSON-RPC schema validation without resolver context."); + } + + SchemaRegistry registry = createSchemaRegistry(resolver); + errorSchema = resolveErrorSchema(registry, resolver, uriFactory, beanBaseLocation); + + Map resolvedParamSchemas = new LinkedHashMap<>(); + Map resolvedResponseSchemas = new LinkedHashMap<>(); + + for (Map.Entry entry : methods.getMethods().entrySet()) { + String methodName = validateMethodName(entry.getKey()); + JsonRPCSchemas definitions = requireDefinitions(methodName, entry.getValue()); + + resolveMethodSchema( + resolvedParamSchemas, + registry, + methodName, + "params", + definitions.getParams(), + resolver, + uriFactory, + beanBaseLocation + ); + resolveMethodSchema( + resolvedResponseSchemas, + registry, + methodName, + "response", + definitions.getResponse(), + resolver, + uriFactory, + beanBaseLocation + ); + } + + paramSchemas = Collections.unmodifiableMap(resolvedParamSchemas); + responseSchemas = Collections.unmodifiableMap(resolvedResponseSchemas); + } + + public boolean hasRequestValidation() { + return !paramSchemas.isEmpty(); + } + + public boolean hasMethodResponseValidation() { + return !responseSchemas.isEmpty(); + } + + public boolean hasErrorValidation() { + return errorSchema != null; + } + + public boolean hasResponseValidation() { + return hasMethodResponseValidation() || hasErrorValidation(); + } + + public boolean isEmpty() { + return !hasRequestValidation() && !hasResponseValidation(); + } + + public Schema getParamSchema(String methodName) { + if (methodName == null) { + return null; + } + return paramSchemas.get(methodName); + } + + public Schema getResponseSchema(String methodName) { + if (methodName == null) { + return null; + } + return responseSchemas.get(methodName); + } + + public Schema getErrorSchema() { + return errorSchema; + } + + private Schema resolveErrorSchema(SchemaRegistry registry, Resolver resolver, URIFactory uriFactory, String beanBaseLocation) { + if (errorValidation == null) { + return null; + } + return resolveConfiguredSchema( + registry, + "", + "error", + errorValidation.getLocation(), + errorValidation.getSchema(), + resolver, + uriFactory, + beanBaseLocation + ); + } + + private void resolveMethodSchema(Map target, + SchemaRegistry registry, + String methodName, + String schemaRole, + SchemaSetter definition, + Resolver resolver, + URIFactory uriFactory, + String beanBaseLocation) { + if (definition == null) { + return; + } + Schema schema = resolveConfiguredSchema( + registry, + methodName, + schemaRole, + definition.getLocation(), + definition.getSchema(), + resolver, + uriFactory, + beanBaseLocation + ); + if (schema != null) { + target.put(methodName, schema); + } + } + + private Schema resolveConfiguredSchema(SchemaRegistry registry, + String methodName, + String schemaRole, + String configuredLocation, + JsonRPCInlineSchema inlineSchema, + Resolver resolver, + URIFactory uriFactory, + String beanBaseLocation) { + String location = normalizeLocation(configuredLocation); + boolean hasLocation = location != null; + boolean hasInlineSchema = inlineSchema != null; + + if (hasLocation == hasInlineSchema) { + throw new ConfigurationException( + "JSON-RPC %s schema for method '%s' must define exactly one of 'location' or 'schema'." + .formatted(schemaRole, methodName) + ); + } + + if (hasLocation) { + return loadSchema( + "JSON-RPC %s schema for method '%s'".formatted(schemaRole, methodName), + registry, + SchemaLocation.of(combine(uriFactory, beanBaseLocation, location)), + resolver, + location + ); + } + + return loadInlineSchema(registry, methodName, schemaRole, inlineSchema, uriFactory, beanBaseLocation); + } + + private Schema loadInlineSchema(SchemaRegistry registry, + String methodName, + String schemaRole, + JsonRPCInlineSchema inlineSchema, + URIFactory uriFactory, + String beanBaseLocation) { + if (inlineSchema == null || inlineSchema.getProperties().isEmpty()) { + throw new ConfigurationException( + "JSON-RPC %s schema for method '%s' must not be empty." + .formatted(schemaRole, methodName) + ); + } + + try { + Schema schema = registry.getSchema( + SchemaLocation.of(createInlineSchemaLocation(methodName, schemaRole, uriFactory, beanBaseLocation)), + OM.valueToTree(inlineSchema.getProperties()) + ); + schema.initializeValidators(); + return schema; + } catch (RuntimeException e) { + throw new ConfigurationException( + "Cannot create inline JSON-RPC %s schema for method '%s'." + .formatted(schemaRole, methodName), + e + ); + } + } + + private String createInlineSchemaLocation(String methodName, String schemaRole, URIFactory uriFactory, String beanBaseLocation) { + String syntheticFile = "__jsonrpc_%s_%s.schema.json".formatted( + sanitize(methodName), + sanitize(schemaRole) + ); + if (beanBaseLocation == null || beanBaseLocation.isBlank()) { + return "membrane:%s".formatted(syntheticFile); + } + return combine(uriFactory, beanBaseLocation, syntheticFile); + } + + private static String sanitize(String value) { + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private Schema loadSchema(String description, + SchemaRegistry registry, + SchemaLocation schemaLocation, + Resolver resolver, + String configuredLocation) { + try (var in = resolver.resolve(schemaLocation.getAbsoluteIri().toString())) { + Schema schema = registry.getSchema(schemaLocation, in, getSchemaFormat(schemaLocation.getAbsoluteIri().toString())); + schema.initializeValidators(); + return schema; + } catch (IOException e) { + throw new ConfigurationException("Cannot read %s from '%s'.".formatted(description, configuredLocation), e); + } catch (RuntimeException e) { + throw new ConfigurationException("Cannot create %s from '%s'.".formatted(description, configuredLocation), e); + } + } + + private static SchemaRegistry createSchemaRegistry(Resolver resolver) { + return withDefaultDialect( + DRAFT_2020_12, + builder -> builder.schemaLoader(SchemaLoader.builder() + .resourceLoaders(loaders -> loaders.values(list -> list.addFirst(new MembraneSchemaLoader(resolver)))) + .build()) + ); + } + + private static InputFormat getSchemaFormat(String schemaLocation) { + String normalized = schemaLocation.toLowerCase(); + return normalized.endsWith(".yaml") || normalized.endsWith(".yml") ? YAML : JSON; + } + + private static JsonRPCSchemas requireDefinitions(String methodName, JsonRPCSchemas definitions) { + if (definitions == null) { + throw new ConfigurationException("JSON-RPC schema validation entry for method '%s' must not be null.".formatted(methodName)); + } + return definitions; + } + + private static String validateMethodName(String methodName) { + if (methodName == null || methodName.trim().isEmpty()) { + throw new ConfigurationException("JSON-RPC method name must not be empty."); + } + return methodName.trim(); + } + + private static String normalizeLocation(String location) { + if (location == null) { + return null; + } + String trimmed = location.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java new file mode 100644 index 0000000000..7abf95651a --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemas.java @@ -0,0 +1,57 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCChildElement; +import com.predic8.membrane.annot.MCElement; + +/** + * @description + *

Defines the JSON Schema validation rules for one exact JSON-RPC method name.

+ * + *

Use params to validate the request payload and response + * to validate successful upstream responses.

+ */ +@MCElement(name = "method", component = false, id = "json-rpc-method-schema") +public class JsonRPCSchemas { + + private JsonRPCParamValidation paramValidation; + + private JsonRPCResponseValidation responseValidation; + + /** + * @description Validates the JSON-RPC params member for this method. + */ + @MCChildElement(order = 1) + public void setParams(JsonRPCParamValidation paramValidation) { + this.paramValidation = paramValidation; + } + + public JsonRPCParamValidation getParams() { + return paramValidation; + } + + /** + * @description Validates the successful JSON-RPC result payload for this method. + */ + @MCChildElement(order = 2) + public void setResponse(JsonRPCResponseValidation responseValidation) { + this.responseValidation = responseValidation; + } + + public JsonRPCResponseValidation getResponse() { + return responseValidation; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java new file mode 100644 index 0000000000..74735a99dd --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCValidator.java @@ -0,0 +1,375 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.networknt.schema.Error; +import com.predic8.membrane.core.jsonrpc.JSONRPCRequest; +import com.predic8.membrane.core.jsonrpc.JSONRPCResponse; +import com.predic8.membrane.core.util.config.allowdeny.Rule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.BATCH; +import static com.predic8.membrane.core.interceptor.json.rpc.JsonRPCValidator.PayloadType.SINGLE; +import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.*; + +public class JsonRPCValidator { + + private static final Logger log = LoggerFactory.getLogger(JsonRPCValidator.class); + private static final ObjectMapper om = new ObjectMapper(); + + private final BatchRule batchRule; + private final List rules; + private final JsonRPCSchemaValidation schemaValidation; + + public JsonRPCValidator(BatchRule batchRule, List rules, JsonRPCSchemaValidation schemaValidation) { + this.batchRule = batchRule; + this.rules = rules; + this.schemaValidation = schemaValidation; + } + + public ValidationError validate(String body) { + return validateRequest(body).error(); + } + + public RequestValidationResult validateRequest(String body) { + if (body == null || body.isBlank()) { + return new RequestValidationResult(null, null); + } + + try { + var payloadType = getPayloadType(body); + var root = om.readTree(body); + return validateRequest(root, payloadType); + } catch (JsonProcessingException e) { + log.debug("Invalid JSON-RPC request payload.", e); + return invalidRequestResult(getPayloadType(body), "Invalid JSON-RPC payload"); + } catch (RuntimeException e) { + log.debug("Invalid JSON-RPC request payload.", e); + return invalidRequestResult(SINGLE, "Invalid JSON-RPC payload"); + } + } + + public ValidationError validateResponse(String body, ResponseValidationContext context) { + if (context == null || !schemaValidation.hasResponseValidation()) { + return null; + } + if (!context.expectsResponses() && !schemaValidation.hasErrorValidation()) { + return null; + } + if (body == null || body.isBlank()) { + return invalidResponse(context.payloadType(), null, "JSON-RPC response must not be empty."); + } + + try { + var root = om.readTree(body); + return validateResponse(root, context); + } catch (Exception e) { + log.debug("Invalid JSON-RPC response payload.", e); + return invalidResponse(context.payloadType(), null, "Invalid JSON-RPC response payload"); + } + } + + private RequestValidationResult validateRequest(JsonNode root, PayloadType payloadType) { + if (root.isObject()) { + return validateSingleRequest(root); + } + if (root.isArray()) { + return validateBatchRequest(root); + } + return invalidRequestResult(payloadType, "JSON-RPC payload must be an object or batch array."); + } + + private RequestValidationResult validateSingleRequest(JsonNode node) { + try { + JSONRPCRequest request = JSONRPCRequest.fromNode(node); + ValidationError error = validateMethod(request, SINGLE); + return new RequestValidationResult(error, createResponseValidationContext(SINGLE, request)); + } catch (IOException e) { + return invalidRequestResult(SINGLE, "Invalid JSON-RPC request: " + e.getMessage()); + } + } + + private RequestValidationResult validateBatchRequest(JsonNode batch) { + if (!batchRule.isEnabled()) { + return invalidRequestResult(BATCH, "Batch requests are disabled."); + } + if (batch.isEmpty()) { + return invalidRequestResult(BATCH, "Batch requests must not be empty."); + } + if (batch.size() > batchRule.getMaxSize()) { + return invalidRequestResult(BATCH, "Batch request exceeds maxSize of " + batchRule.getMaxSize() + "."); + } + + Map methodsById = new LinkedHashMap<>(); + for (JsonNode requestNode : batch) { + if (!requestNode.isObject()) { + return invalidRequestResult(BATCH, "Each batch entry must be a JSON-RPC request object."); + } + + try { + JSONRPCRequest request = JSONRPCRequest.fromNode(requestNode); + ValidationError error = validateMethod(request, BATCH); + if (error != null) { + return new RequestValidationResult(error, null); + } + rememberResponseMethod(methodsById, request); + } catch (IOException e) { + return invalidRequestResult(BATCH, "Invalid JSON-RPC request in batch: " + e.getMessage()); + } + } + return new RequestValidationResult(null, createResponseValidationContext(BATCH, methodsById)); + } + + private ValidationError validateResponse(JsonNode root, ResponseValidationContext context) { + if (context.payloadType() == SINGLE) { + if (!root.isObject()) { + return invalidResponse(SINGLE, null, "JSON-RPC response must be an object."); + } + return validateSingleResponse(root, context, SINGLE); + } + + if (!root.isArray()) { + return invalidResponse(BATCH, null, "JSON-RPC batch response must be an array."); + } + return validateBatchResponse(root, context); + } + + private ValidationError validateSingleResponse(JsonNode node, ResponseValidationContext context, PayloadType payloadType) { + JSONRPCResponse response; + try { + response = parse(node.toString()); + } catch (IOException e) { + return invalidResponse(payloadType, null, "Invalid JSON-RPC response: " + e.getMessage()); + } + + if (response.isError()) { + return validateErrorResponse(node, payloadType, response.getId()); + } + + if (!schemaValidation.hasMethodResponseValidation()) { + return null; + } + + String methodName = context.methodFor(response.getId()); + if (methodName == null) { + return invalidResponse(payloadType, response.getId(), "JSON-RPC response id '%s' does not match any request.".formatted(response.getId())); + } + + var schema = schemaValidation.getResponseSchema(methodName); + if (schema == null) { + return null; + } + + var errors = schema.validate(getResultNode(response)); + if (errors.isEmpty()) { + return null; + } + + return invalidResponse( + payloadType, + response.getId(), + "Invalid result for method '%s': %s".formatted( + methodName, + errors.stream().map(Error::getMessage).collect(Collectors.joining("; ")) + ) + ); + } + + private ValidationError validateBatchResponse(JsonNode batch, ResponseValidationContext context) { + if (batch.isEmpty()) { + return invalidResponse(BATCH, null, "Batch responses must not be empty."); + } + + for (JsonNode responseNode : batch) { + if (!responseNode.isObject()) { + return invalidResponse(BATCH, null, "Each batch entry must be a JSON-RPC response object."); + } + + ValidationError error = validateSingleResponse(responseNode, context, BATCH); + if (error != null) { + return error; + } + } + return null; + } + + private ValidationError validateMethod(JSONRPCRequest request, PayloadType payloadType) { + for (var rule : rules) { + if (!rule.matches(request.getMethod())) { + continue; + } + if (rule.permits()) { + break; + } + return new ValidationError( + payloadType, + responseId(request), + 403, + ERR_METHOD_NOT_FOUND, + "JSON-RPC method '%s' is not allowed.".formatted(request.getMethod()) + ); + } + return validateParams(request, payloadType); + } + + private ValidationError validateParams(JSONRPCRequest request, PayloadType payloadType) { + var schema = schemaValidation.getParamSchema(request.getMethod()); + if (schema == null) { + return null; + } + + var errors = schema.validate(getParamsNode(request)); + if (errors.isEmpty()) { + return null; + } + + return new ValidationError( + payloadType, + responseId(request), + 400, + ERR_INVALID_PARAMS, + "Invalid params for method '%s': %s".formatted( + request.getMethod(), + errors.stream().map(Error::getMessage).collect(Collectors.joining("; ")) + ) + ); + } + + private RequestValidationResult invalidRequestResult(PayloadType payloadType, String message) { + return new RequestValidationResult(new ValidationError(payloadType, null, 400, ERR_INVALID_REQUEST, message), null); + } + + private ValidationError invalidResponse(PayloadType payloadType, Object responseId, String message) { + return new ValidationError(payloadType, responseId, 500, ERR_INTERNAL_ERROR, message); + } + + private ResponseValidationContext createResponseValidationContext(PayloadType payloadType, JSONRPCRequest request) { + Map methodsById = new LinkedHashMap<>(); + rememberResponseMethod(methodsById, request); + return createResponseValidationContext(payloadType, methodsById); + } + + private ResponseValidationContext createResponseValidationContext(PayloadType payloadType, Map methodsById) { + if (!schemaValidation.hasResponseValidation()) { + return null; + } + if (methodsById.isEmpty() && !schemaValidation.hasErrorValidation()) { + return null; + } + return new ResponseValidationContext(payloadType, Collections.unmodifiableMap(new LinkedHashMap<>(methodsById))); + } + + private ValidationError validateErrorResponse(JsonNode node, PayloadType payloadType, Object responseId) { + var schema = schemaValidation.getErrorSchema(); + if (schema == null) { + return null; + } + + var errors = schema.validate(node.path("error")); + if (errors.isEmpty()) { + return null; + } + + return invalidResponse( + payloadType, + responseId, + "Invalid error response: %s".formatted( + errors.stream().map(Error::getMessage).collect(Collectors.joining("; ")) + ) + ); + } + + private void rememberResponseMethod(Map methodsById, JSONRPCRequest request) { + if (request == null || request.isNotification()) { + return; + } + methodsById.putIfAbsent(request.getId(), request.getMethod()); + } + + private Object responseId(JSONRPCRequest request) { + if (request == null || request.isNotification()) { + return null; + } + return request.getId(); + } + + private JsonNode getParamsNode(JSONRPCRequest request) { + Object params = request.getParams(); + if (params == null) { + return NullNode.instance; + } + return om.valueToTree(params); + } + + private JsonNode getResultNode(JSONRPCResponse response) { + Object result = response.getResult(); + if (result == null) { + return NullNode.instance; + } + return om.valueToTree(result); + } + + private PayloadType getPayloadType(String body) { + if (body == null) { + return SINGLE; + } + return body.trim().startsWith("[") ? BATCH : SINGLE; + } + + public enum PayloadType { + SINGLE, + BATCH + } + + public record RequestValidationResult( + ValidationError error, + ResponseValidationContext responseValidationContext + ) { + } + + public record ResponseValidationContext( + PayloadType payloadType, + Map methodsById + ) { + public boolean expectsResponses() { + return !methodsById.isEmpty(); + } + + public String methodFor(Object responseId) { + return methodsById.get(responseId); + } + } + + public record ValidationError( + PayloadType payloadType, + Object responseId, + int httpStatus, + int code, + String message + ) { + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java new file mode 100644 index 0000000000..c1b94ac050 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/SchemaSetter.java @@ -0,0 +1,56 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.predic8.membrane.annot.MCAttribute; +import com.predic8.membrane.annot.MCChildElement; + +public class SchemaSetter { + + protected String location; + protected JsonRPCInlineSchema schema; + + /** + * @description + *

References the JSON Schema by path or URL.

+ * + *

Configure either location or schema, but not both.

+ * + * @example classpath:/json/rpc/echo-params.schema.json + */ + @MCAttribute + public void setLocation(String location) { + this.location = location; + } + + public String getLocation() { + return location; + } + + /** + * @description + *

Defines the JSON Schema inline.

+ * + *

Configure either schema or location, but not both.

+ */ + @MCChildElement(order = 1) + public void setSchema(JsonRPCInlineSchema schema) { + this.schema = schema; + } + + public JsonRPCInlineSchema getSchema() { + return schema; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java b/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java index f9b802c163..2e66a98e23 100644 --- a/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java +++ b/core/src/main/java/com/predic8/membrane/core/jsonrpc/JSONRPCRequest.java @@ -109,7 +109,7 @@ public static JSONRPCRequest parse(String json) throws IOException { return fromNode(OM.readTree(json)); } - private static JSONRPCRequest fromNode(JsonNode root) throws IOException { + public static JSONRPCRequest fromNode(JsonNode root) throws IOException { if (root == null || !root.isObject()) throw new IOException("Invalid JSON-RPC request: expected JSON object"); JSONRPCRequest req = new JSONRPCRequest(); diff --git a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java new file mode 100644 index 0000000000..cd2c12f13f --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Allow.java @@ -0,0 +1,34 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.util.config.allowdeny; + +import com.predic8.membrane.annot.MCElement; + +/** + * @description Permits values matching the configured regular expression. + */ +@MCElement(name = "allow", collapsed = true, component = false, id = "allow-rule") +public class Allow extends Rule { + + @Override + public boolean permits() { + return true; + } + + @Override + public String toString() { + return "Allow{pattern=%s}".formatted(getPattern()); + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java new file mode 100644 index 0000000000..cdd8052be9 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Deny.java @@ -0,0 +1,34 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.util.config.allowdeny; + +import com.predic8.membrane.annot.MCElement; + +/** + * @description Denies values matching the configured regular expression. + */ +@MCElement(name = "deny", collapsed = true, component = false, id = "deny-rule") +public class Deny extends Rule { + + @Override + public boolean permits() { + return false; + } + + @Override + public String toString() { + return "Deny{pattern=%s}".formatted(getPattern()); + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java new file mode 100644 index 0000000000..a582d6bd87 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/config/allowdeny/Rule.java @@ -0,0 +1,73 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.util.config.allowdeny; + +import com.predic8.membrane.annot.MCAttribute; +import com.predic8.membrane.annot.Required; +import com.predic8.membrane.core.util.ConfigurationException; + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Ordered allow/deny rule based on a regular expression. + */ +public abstract class Rule { + + private String pattern; + private Pattern compiledPattern; + + public boolean matches(String probe) { + if (probe == null) { + return false; + } + return compiledPattern != null && compiledPattern.matcher(probe).matches(); + } + + public abstract boolean permits(); + + /** + * @description The regular expression matched against the input value. + * @example "^rpc\\.(health|echo)$" + */ + @Required + @MCAttribute + public void setPattern(String pattern) { + if (pattern == null || pattern.trim().isEmpty()) { + throw new ConfigurationException("pattern must not be empty"); + } + + this.pattern = pattern.trim(); + try { + compiledPattern = Pattern.compile(this.pattern); + } catch (PatternSyntaxException e) { + throw new ConfigurationException("Invalid regex pattern: " + this.pattern); + } + } + + public String getPattern() { + return pattern; + } + + @Deprecated + public void setMethod(String method) { + setPattern(method); + } + + @Deprecated + public String getMethod() { + return getPattern(); + } +} diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java new file mode 100644 index 0000000000..ce476aff21 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCProtectionInterceptorTest.java @@ -0,0 +1,898 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.core.interceptor.json.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.predic8.membrane.annot.beanregistry.BeanRegistryImplementation; +import com.predic8.membrane.annot.yaml.ParsingContext; +import com.predic8.membrane.annot.yaml.parsing.GenericYamlParser; +import com.predic8.membrane.core.config.spring.GrammarAutoGenerated; +import com.predic8.membrane.core.exchange.Exchange; +import com.predic8.membrane.core.http.Request; +import com.predic8.membrane.core.http.Response; +import com.predic8.membrane.core.interceptor.Outcome; +import com.predic8.membrane.core.router.DefaultRouter; +import com.predic8.membrane.core.util.ConfigurationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Map; +import java.util.stream.Stream; + +import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON; +import static com.predic8.membrane.core.http.MimeType.TEXT_PLAIN; +import static com.predic8.membrane.core.http.Request.METHOD_GET; +import static com.predic8.membrane.core.http.Request.METHOD_POST; +import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE; +import static com.predic8.membrane.core.interceptor.Outcome.RETURN; +import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INTERNAL_ERROR; +import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_PARAMS; +import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_INVALID_REQUEST; +import static com.predic8.membrane.core.jsonrpc.JSONRPCResponse.ERR_METHOD_NOT_FOUND; +import static org.junit.jupiter.api.Assertions.*; + +class JsonRPCProtectionInterceptorTest { + + private static final ObjectMapper OM = new ObjectMapper(); + private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); + + private static final String PARAMS_LOCATION_CONFIG = """ + schemaValidation: + methods: + 'rpc.echo': + params: + location: classpath:/json/rpc/echo-params.schema.json + """; + private static final String PARAMS_INLINE_CONFIG = """ + schemaValidation: + methods: + 'rpc.inline': + params: + schema: + type: object + required: + - message + additionalProperties: false + properties: + message: + type: string + """; + private static final String RESPONSE_LOCATION_CONFIG = """ + schemaValidation: + methods: + 'rpc.echo': + response: + location: classpath:/json/rpc/echo-result.schema.json + """; + private static final String RESPONSE_INLINE_CONFIG = """ + schemaValidation: + methods: + 'rpc.inline': + response: + schema: + type: object + required: + - ok + additionalProperties: false + properties: + ok: + type: boolean + """; + private static final String BATCH_RESPONSE_CONFIG = """ + schemaValidation: + methods: + 'rpc.echo': + response: + location: classpath:/json/rpc/echo-result.schema.json + 'rpc.health': + response: + location: classpath:/json/rpc/generic-rpc-params.schema.json + """; + private static final String ERROR_LOCATION_CONFIG = """ + schemaValidation: + error: + location: classpath:/json/rpc/error.schema.json + """; + private static final String ERROR_INLINE_CONFIG = """ + schemaValidation: + error: + schema: + type: object + required: + - code + - message + - data + properties: + code: + type: integer + message: + type: string + data: + type: object + required: + - reason + properties: + reason: + type: string + """; + + @ParameterizedTest(name = "{0}") + @MethodSource("requestCases") + void validatesRequests(RequestCase testCase) throws Exception { + var interceptor = interceptor(testCase.config()); + var exc = exchange(testCase.method(), testCase.contentType(), testCase.body()); + + assertEquals(testCase.expectedOutcome(), interceptor.handleRequest(exc)); + + if (testCase.expectsRejection()) { + assertValidationError( + exc.getResponse(), + testCase.expectedStatus(), + testCase.expectedJsonRpcCode(), + testCase.expectedMessageSnippet(), + testCase.expectedId(), + testCase.batchErrorShape() + ); + return; + } + + assertNull(exc.getResponse()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("responseCases") + void validatesResponses(ResponseCase testCase) throws Exception { + var interceptor = interceptor(testCase.config()); + var exc = exchange(METHOD_POST, APPLICATION_JSON, testCase.requestBody()); + + if (testCase.requestBody() != null) { + assertEquals(CONTINUE, interceptor.handleRequest(exc), "response test setup must pass request validation"); + } + + exc.setResponse(jsonResponse(testCase.responseBody())); + assertEquals(testCase.expectedOutcome(), interceptor.handleResponse(exc)); + + if (testCase.expectsRejection()) { + assertValidationError( + exc.getResponse(), + testCase.expectedStatus(), + testCase.expectedJsonRpcCode(), + testCase.expectedMessageSnippet(), + testCase.expectedId(), + testCase.batchErrorShape() + ); + return; + } + + assertNotNull(exc.getResponse()); + assertTrue(exc.getResponse().getBodyAsStringDecoded().contains("\"jsonrpc\"")); + } + + @Test + void parsesSchemaValidationConfigFromYaml() throws Exception { + var interceptor = parseInterceptor(""" + schemaValidation: + error: + schema: + type: object + required: + - code + properties: + code: + type: integer + methods: + 'rpc.echo': + params: + location: classpath:/json/rpc/echo-params.schema.json + response: + schema: + type: object + properties: + message: + type: string + 'rpc.health': + response: + location: classpath:/json/rpc/generic-rpc-params.schema.json + """); + + JsonRPCSchemaValidation schemaValidation = interceptor.getSchemaValidation(); + assertNotNull(schemaValidation); + assertNotNull(schemaValidation.getErrorValidation()); + assertEquals( + "object", + schemaValidation.getErrorValidation().getSchema().getProperties().get("type") + ); + + Map methods = schemaValidation.getMethods().getMethods(); + assertEquals( + "classpath:/json/rpc/echo-params.schema.json", + methods.get("rpc.echo").getParams().getLocation() + ); + assertEquals( + "string", + ((Map) ((Map) methods.get("rpc.echo").getResponse().getSchema().getProperties().get("properties")).get("message")).get("type") + ); + assertEquals( + "classpath:/json/rpc/generic-rpc-params.schema.json", + methods.get("rpc.health").getResponse().getLocation() + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidConfigCases") + void rejectsInvalidConfigurations(InvalidConfigCase testCase) { + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> interceptor(testCase.config()) + ); + + assertTrue(containsMessage(exception, testCase.expectedMessageSnippet())); + } + + @Test + void batchMaxSizeMustBePositive() { + ConfigurationException exception = assertThrows( + ConfigurationException.class, + () -> new BatchRule().setMaxSize(0) + ); + + assertTrue(exception.getMessage().contains("batch maxSize must be greater than 0")); + } + + private static Stream requestCases() { + return Stream.of( + requestContinues( + "allow rule wins before later deny", + """ + methods: + - allow: '^rpc\\.health$' + - deny: '^rpc\\..*$' + """, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.health"} + """ + ), + requestRejects( + "first matching deny rule rejects request", + """ + methods: + - deny: '^rpc\\..*$' + - allow: '^rpc\\.health$' + """, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.health"} + """, + 403, + ERR_METHOD_NOT_FOUND, + "JSON-RPC method 'rpc.health' is not allowed.", + 1 + ), + requestContinues( + "non-POST requests are ignored", + "", + METHOD_GET, + TEXT_PLAIN, + "not-json" + ), + requestRejects( + "non-JSON content type is rejected", + "", + METHOD_POST, + TEXT_PLAIN, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.health"} + """, + 415, + ERR_INVALID_REQUEST, + "Content-Type text/plain is not supported. Expected application/json.", + null + ), + requestContinues( + "blank POST bodies are ignored", + "", + METHOD_POST, + APPLICATION_JSON, + " " + ), + requestRejects( + "invalid JSON payload is rejected", + "", + "not-json", + 400, + ERR_INVALID_REQUEST, + "Invalid JSON-RPC payload", + null + ), + requestRejects( + "payload must be an object or batch array", + "", + "1", + 400, + ERR_INVALID_REQUEST, + "JSON-RPC payload must be an object or batch array.", + null + ), + requestRejects( + "method must be textual", + "", + """ + {"jsonrpc":"2.0","id":1,"method":1} + """, + 400, + ERR_INVALID_REQUEST, + "'method' must be a string", + null + ), + requestRejects( + "params must be an object or array", + "", + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":1} + """, + 400, + ERR_INVALID_REQUEST, + "'params' must be array or object", + null + ), + requestRejects( + "batch requests can be disabled", + """ + batch: + enabled: false + """, + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.health"}] + """, + 400, + ERR_INVALID_REQUEST, + "Batch requests are disabled.", + null + ), + requestRejects( + "batch size is limited", + """ + batch: + maxSize: 1 + """, + """ + [ + {"jsonrpc":"2.0","id":1,"method":"rpc.one"}, + {"jsonrpc":"2.0","id":2,"method":"rpc.two"} + ] + """, + 400, + ERR_INVALID_REQUEST, + "Batch request exceeds maxSize of 1.", + null + ), + requestRejects( + "batch requests must not be empty", + "", + "[]", + 400, + ERR_INVALID_REQUEST, + "Batch requests must not be empty.", + null + ), + requestRejects( + "every batch entry must be an object", + "", + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.echo"},1] + """, + 400, + ERR_INVALID_REQUEST, + "Each batch entry must be a JSON-RPC request object.", + null + ), + requestContinues( + "location-based params schema accepts matching payload", + PARAMS_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"hello"}} + """ + ), + requestRejects( + "location-based params schema rejects invalid payload", + PARAMS_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}} + """, + 400, + ERR_INVALID_PARAMS, + "Invalid params for method 'rpc.echo'", + 1 + ), + requestContinues( + "inline params schema accepts matching payload", + PARAMS_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline","params":{"message":"hello"}} + """ + ), + requestRejects( + "inline params schema rejects invalid payload", + PARAMS_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline","params":{}} + """, + 400, + ERR_INVALID_PARAMS, + "Invalid params for method 'rpc.inline'", + 1 + ), + requestContinues( + "params validation uses exact method names", + PARAMS_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2","params":{}} + """ + ), + requestRejects( + "notifications are rejected without a response id when a deny rule matches", + """ + methods: + - deny: '^rpc\\..*$' + - allow: '^rpc\\.health$' + """, + """ + {"jsonrpc":"2.0","method":"rpc.health"} + """, + 403, + ERR_METHOD_NOT_FOUND, + "JSON-RPC method 'rpc.health' is not allowed.", + null + ) + ); + } + + private static Stream responseCases() { + return Stream.of( + responseContinues( + "location-based response schema accepts matching result", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{"message":"hello"}} + """ + ), + responseRejects( + "location-based response schema rejects invalid result", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """, + "Invalid result for method 'rpc.echo'", + 1 + ), + responseContinues( + "inline response schema accepts matching result", + RESPONSE_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{"ok":true}} + """ + ), + responseRejects( + "inline response schema rejects invalid result", + RESPONSE_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.inline"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """, + "Invalid result for method 'rpc.inline'", + 1 + ), + responseContinues( + "response validation uses exact method names", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo.v2"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """ + ), + responseRejects( + "single responses must be objects", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + "[]", + "JSON-RPC response must be an object.", + null + ), + responseRejects( + "unknown response ids are rejected", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":9,"result":{"message":"hello"}} + """, + "JSON-RPC response id '9' does not match any request.", + 9 + ), + responseRejects( + "batch response validation resolves methods by request id", + BATCH_RESPONSE_CONFIG, + """ + [ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"}, + {"jsonrpc":"2.0","id":2,"method":"rpc.health"} + ] + """, + """ + [ + {"jsonrpc":"2.0","id":2,"result":{"code":1}}, + {"jsonrpc":"2.0","id":1,"result":{}} + ] + """, + "Invalid result for method 'rpc.echo'", + 1 + ), + responseRejects( + "batch responses must not be empty", + BATCH_RESPONSE_CONFIG, + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.echo"}] + """, + "[]", + "Batch responses must not be empty.", + null + ), + responseRejects( + "every batch response entry must be an object", + BATCH_RESPONSE_CONFIG, + """ + [{"jsonrpc":"2.0","id":1,"method":"rpc.echo"}] + """, + "[1]", + "Each batch entry must be a JSON-RPC response object.", + null + ), + responseContinues( + "notification responses are ignored when only success schemas are configured", + RESPONSE_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"result":{}} + """ + ), + responseContinues( + "location-based error schema accepts matching error payload", + ERROR_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} + """ + ), + responseRejects( + "location-based error schema rejects invalid error payload", + ERROR_LOCATION_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """, + "Invalid error response", + 1 + ), + responseContinues( + "inline error schema accepts matching error payload", + ERROR_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken","data":{"reason":"timeout"}}} + """ + ), + responseRejects( + "inline error schema rejects invalid error payload", + ERROR_INLINE_CONFIG, + """ + {"jsonrpc":"2.0","id":1,"method":"rpc.echo"} + """, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """, + "Invalid error response", + 1 + ), + responseRejects( + "error validation also works without prior request context", + ERROR_INLINE_CONFIG, + null, + """ + {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"broken"}} + """, + "Invalid error response", + 1 + ) + ); + } + + private static Stream invalidConfigCases() { + return Stream.of( + invalidConfig( + "invalid regex patterns are rejected", + """ + methods: + - allow: '[*' + """, + "Invalid regex pattern: [*" + ), + invalidConfig( + "schema definitions must not use location and inline schema together", + """ + schemaValidation: + methods: + 'rpc.echo': + params: + location: classpath:/json/rpc/echo-params.schema.json + schema: + type: object + """, + "must define exactly one of 'location' or 'schema'" + ), + invalidConfig( + "inline schemas must not be empty", + """ + schemaValidation: + methods: + 'rpc.echo': + response: + schema: {} + """, + "must not be empty" + ), + invalidConfig( + "error schemas also require location or inline schema", + """ + schemaValidation: + error: {} + """, + "must define exactly one of 'location' or 'schema'" + ) + ); + } + + private static RequestCase requestContinues(String name, String config, String body) { + return requestContinues(name, config, METHOD_POST, APPLICATION_JSON, body); + } + + private static RequestCase requestContinues(String name, String config, String method, String contentType, String body) { + return new RequestCase(name, config, method, contentType, body, CONTINUE, null, null, null, null, false); + } + + private static RequestCase requestRejects(String name, + String config, + String body, + int expectedStatus, + int expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId) { + return requestRejects(name, config, METHOD_POST, APPLICATION_JSON, body, expectedStatus, expectedJsonRpcCode, expectedMessageSnippet, expectedId); + } + + private static RequestCase requestRejects(String name, + String config, + String method, + String contentType, + String body, + int expectedStatus, + int expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId) { + return new RequestCase( + name, + config, + method, + contentType, + body, + RETURN, + expectedStatus, + expectedJsonRpcCode, + expectedMessageSnippet, + expectedId, + payloadIsBatch(body) + ); + } + + private static ResponseCase responseContinues(String name, String config, String requestBody, String responseBody) { + return new ResponseCase(name, config, requestBody, responseBody, CONTINUE, null, null, null, null, false); + } + + private static ResponseCase responseRejects(String name, + String config, + String requestBody, + String responseBody, + String expectedMessageSnippet, + Object expectedId) { + return new ResponseCase( + name, + config, + requestBody, + responseBody, + RETURN, + 500, + ERR_INTERNAL_ERROR, + expectedMessageSnippet, + expectedId, + payloadIsBatch(requestBody != null ? requestBody : responseBody) + ); + } + + private static InvalidConfigCase invalidConfig(String name, String config, String expectedMessageSnippet) { + return new InvalidConfigCase(name, config, expectedMessageSnippet); + } + + private JsonRPCProtectionInterceptor interceptor(String config) throws Exception { + JsonRPCProtectionInterceptor interceptor = parseInterceptor(config); + interceptor.init(new DefaultRouter()); + return interceptor; + } + + private JsonRPCProtectionInterceptor parseInterceptor(String config) throws Exception { + JsonNode node = YAML.readTree(wrapConfig(config)); + var grammar = new GrammarAutoGenerated(); + var registry = new BeanRegistryImplementation(grammar); + return GenericYamlParser.createAndPopulateNode( + new ParsingContext<>("jsonRPCProtection", registry, grammar, node, "$.jsonRPCProtection", null), + JsonRPCProtectionInterceptor.class, + node.get("jsonRPCProtection") + ); + } + + private String wrapConfig(String config) { + if (config == null || config.isBlank()) { + return "jsonRPCProtection: {}\n"; + } + return "jsonRPCProtection:\n" + config.stripIndent().indent(2); + } + + private Exchange exchange(String method, String contentType, String body) { + Request.Builder builder = new Request.Builder() + .method(method) + .uri("/"); + + if (contentType != null) { + builder.contentType(contentType); + } + if (body != null) { + builder.body(body); + } + + return builder.buildExchange(); + } + + private Response jsonResponse(String body) { + return Response.ok() + .json(body) + .build(); + } + + private void assertValidationError(Response response, + int expectedStatus, + int expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId, + boolean batchShape) throws Exception { + assertNotNull(response); + assertEquals(expectedStatus, response.getStatusCode()); + + JsonNode root = OM.readTree(response.getBodyAsStringDecoded()); + if (batchShape) { + assertTrue(root.isArray()); + assertEquals(1, root.size()); + root = root.get(0); + } else { + assertTrue(root.isObject()); + } + + assertEquals("2.0", root.path("jsonrpc").asText()); + assertEquals(expectedJsonRpcCode, root.path("error").path("code").asInt()); + assertTrue(root.path("error").path("message").asText().contains(expectedMessageSnippet)); + + JsonNode idNode = root.get("id"); + assertNotNull(idNode); + if (expectedId == null) { + assertTrue(idNode.isNull()); + } else { + assertEquals(OM.valueToTree(expectedId), idNode); + } + } + + private static boolean payloadIsBatch(String payload) { + return payload != null && payload.trim().startsWith("["); + } + + private boolean containsMessage(Throwable throwable, String expectedMessageSnippet) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current.getMessage() != null && current.getMessage().contains(expectedMessageSnippet)) { + return true; + } + } + return false; + } + + private record RequestCase(String name, + String config, + String method, + String contentType, + String body, + Outcome expectedOutcome, + Integer expectedStatus, + Integer expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId, + boolean batchErrorShape) { + private boolean expectsRejection() { + return expectedOutcome == RETURN; + } + + @Override + public String toString() { + return name; + } + } + + private record ResponseCase(String name, + String config, + String requestBody, + String responseBody, + Outcome expectedOutcome, + Integer expectedStatus, + Integer expectedJsonRpcCode, + String expectedMessageSnippet, + Object expectedId, + boolean batchErrorShape) { + private boolean expectsRejection() { + return expectedOutcome == RETURN; + } + + @Override + public String toString() { + return name; + } + } + + private record InvalidConfigCase(String name, String config, String expectedMessageSnippet) { + @Override + public String toString() { + return name; + } + } +} diff --git a/core/src/test/resources/json/rpc/echo-params.schema.json b/core/src/test/resources/json/rpc/echo-params.schema.json new file mode 100644 index 0000000000..f0b58898d6 --- /dev/null +++ b/core/src/test/resources/json/rpc/echo-params.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } +} diff --git a/core/src/test/resources/json/rpc/echo-result.schema.json b/core/src/test/resources/json/rpc/echo-result.schema.json new file mode 100644 index 0000000000..9922c4c7f9 --- /dev/null +++ b/core/src/test/resources/json/rpc/echo-result.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } +} diff --git a/core/src/test/resources/json/rpc/error.schema.json b/core/src/test/resources/json/rpc/error.schema.json new file mode 100644 index 0000000000..260573dea9 --- /dev/null +++ b/core/src/test/resources/json/rpc/error.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["code", "message", "data"], + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": ["reason"], + "properties": { + "reason": { + "type": "string" + } + } + } + } +} diff --git a/core/src/test/resources/json/rpc/generic-rpc-params.schema.json b/core/src/test/resources/json/rpc/generic-rpc-params.schema.json new file mode 100644 index 0000000000..05ff6df485 --- /dev/null +++ b/core/src/test/resources/json/rpc/generic-rpc-params.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["code"], + "additionalProperties": false, + "properties": { + "code": { + "type": "integer" + } + } +} diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java new file mode 100644 index 0000000000..1f0c8e49b9 --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/json/JsonRpcProtectionTutorialTest.java @@ -0,0 +1,134 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + 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.predic8.membrane.tutorials.json; + +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static io.restassured.http.ContentType.JSON; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +public class JsonRpcProtectionTutorialTest extends AbstractJsonTutorialTest { + + @Override + protected String getTutorialYaml() { + return "30-JSON-RPC-Protection.yaml"; + } + + @Test + void allowsConfiguredMethods() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.health"} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo(1)) + .body("result.message", equalTo("Hello")); + // @formatter:on + } + + @Test + void validatesConfiguredResultSchema() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"Hello"}} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo(1)) + .body("result.message", equalTo("Hello")); + // @formatter:on + } + + @Test + void rejectsMethodsOutsideAllowlist() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.admin.shutdown"} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(403) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo(1)) + .body("error.code", equalTo(-32601)) + .body("error.message", containsString("rpc.admin.shutdown")); + // @formatter:on + } + + @Test + void validatesParamsAgainstSchema() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(400) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo(1)) + .body("error.code", equalTo(-32602)) + .body("error.message", containsString("Invalid params for method 'rpc.echo'")); + // @formatter:on + } + + @Test + void rejectsBatchesThatExceedMaxSize() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + [ + {"jsonrpc":"2.0","id":5,"method":"rpc.health"}, + {"jsonrpc":"2.0","id":6,"method":"rpc.echo","params":{"message":"Hi"}}, + {"jsonrpc":"2.0","id":7,"method":"rpc.health"} + ] + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(400) + .contentType(JSON) + .body("size()", equalTo(1)) + .body("[0].jsonrpc", equalTo("2.0")) + .body("[0].id", nullValue()) + .body("[0].error.code", equalTo(-32600)) + .body("[0].error.message", equalTo("Batch request exceeds maxSize of 2.")); + // @formatter:on + } +} diff --git a/distribution/tutorials/README.md b/distribution/tutorials/README.md index 6e0d82988d..2f76fdc3bf 100644 --- a/distribution/tutorials/README.md +++ b/distribution/tutorials/README.md @@ -12,7 +12,7 @@ Complete this tutorial before moving on to the JSON or XML tutorials. ## [JSON](json) -This tutorial builds on 'Getting Started'. It explains how to read, create and transform JSON data, and how to use JsonPath to extract information or compute values. +This tutorial builds on 'Getting Started'. It explains how to read, create and transform JSON data, how to use JsonPath to extract information or compute values, and how to protect JSON-RPC endpoints. ## [XML](xml) diff --git a/distribution/tutorials/json/30-JSON-RPC-Protection.yaml b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml new file mode 100644 index 0000000000..50dd61fcbd --- /dev/null +++ b/distribution/tutorials/json/30-JSON-RPC-Protection.yaml @@ -0,0 +1,88 @@ +# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.2.json +# +# Tutorial: JSON-RPC Protection +# +# Protect a JSON-RPC endpoint by: +# - allowing only selected methods +# - rejecting all other methods +# - validating params and responses with JSON Schema +# - limiting batch requests +# +# Notes: +# - Method rules are evaluated top-down. +# - The first matching rule wins. +# - schemaValidation.methods keys are exact JSON-RPC method names. +# - This tutorial shows both schema styles: inline `schema` and file-based `location`. +# - The mock upstream below returns one fixed JSON-RPC success response. +# +# 1.) Send an allowed request +# curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.health"}' -H "Content-Type: application/json" http://localhost:2000 +# +# 2.) Try a blocked method +# curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.admin.shutdown"}' -H "Content-Type: application/json" http://localhost:2000 +# +# Should return a JSON-RPC error explaining that the method is not allowed. +# +# 3.) Try invalid params +# curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{}}' -H "Content-Type: application/json" http://localhost:2000 +# +# The inline schema requires params.message. +# +# 4.) Send a valid request +# curl -d '{"jsonrpc":"2.0","id":1,"method":"rpc.echo","params":{"message":"Hello"}}' -H "Content-Type: application/json" http://localhost:2000 +# +# The request should be forwarded to the mock upstream on port 2001 and the +# JSON-RPC result should pass response validation. +# +# 5.) Try a batch that is too large +# curl -d '[{"jsonrpc":"2.0","id":5,"method":"rpc.health"},{"jsonrpc":"2.0","id":6,"method":"rpc.echo","params":{"message":"Hi"}},{"jsonrpc":"2.0","id":7,"method":"rpc.health"}]' -H "Content-Type: application/json" http://localhost:2000 +# +# Should return a JSON-RPC batch error because maxSize is 2. + +api: + port: 2000 + flow: + - jsonRPCProtection: + batch: + enabled: true + maxSize: 2 # At most two calls per batch + methods: + - allow: '^rpc\.(health|echo)$' + - deny: '.*' # Default deny for everything else + schemaValidation: + methods: + 'rpc.echo': + params: + schema: # Inline schema for rpc.echo params + type: object + required: + - message + properties: + message: + type: string + response: + location: echo-result.schema.json # Validate result for rpc.echo + target: + url: http://localhost:2001 + +--- +api: + port: 2001 + flow: + - request: + - log: + message: "Accepted JSON-RPC request reached the upstream." + - response: + - static: + contentType: application/json + pretty: true + src: | + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "message": "Hello" + } + } + - return: + status: 200 diff --git a/distribution/tutorials/json/README.md b/distribution/tutorials/json/README.md index d2a149b503..2186d50737 100644 --- a/distribution/tutorials/json/README.md +++ b/distribution/tutorials/json/README.md @@ -4,7 +4,8 @@ This tutorial covers working with JSON in Membrane API Gateway, including: - JsonPath - JSON message transformations +- JSON-RPC protection To begin, open [10-JSONPath.yaml](10-JSONPath.yaml) and follow the instructions in the file. -More examples are available in the examples folder. \ No newline at end of file +More examples are available in the examples folder. diff --git a/distribution/tutorials/json/echo-params.schema.json b/distribution/tutorials/json/echo-params.schema.json new file mode 100644 index 0000000000..0b9ad4703d --- /dev/null +++ b/distribution/tutorials/json/echo-params.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "additionalProperties": false +} diff --git a/distribution/tutorials/json/echo-result.schema.json b/distribution/tutorials/json/echo-result.schema.json new file mode 100644 index 0000000000..0b9ad4703d --- /dev/null +++ b/distribution/tutorials/json/echo-result.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "additionalProperties": false +}