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..791ed2df82 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,23 @@ public String getPath() { return path; } + /** + * Appends a property name to a Jayway JSONPath. + * `foo` becomes `.foo`. + * `rpc.echo` becomes `['rpc.echo']`. + * The `[]` form is needed when the property name itself contains characters + * such as `.` or `'` that JSONPath would otherwise interpret as syntax. + */ + 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..754993e395 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,62 @@ 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); + /** + * Returns the last part of a JSONPath created by {@link ParsingContext}. + * Examples: + * `$.api.methods` -> `methods` + * `$.api.methods['rpc.echo']` -> `rpc.echo` + * `$.api.methods[0]` -> `0` + */ + private static String getLastSegment(String jsonPath) { + String segment = jsonPath.substring(findLastSegmentStart(jsonPath)); + + if (isPropertySegment(segment)) { + return segment.substring(1); + } + + if (isQuotedPropertySegment(segment)) { + return decodeQuotedPropertySegment(segment); } + + if (isArrayIndexSegment(segment)) { + 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 boolean isPropertySegment(String segment) { + return segment.startsWith("."); + } + + private static boolean isQuotedPropertySegment(String segment) { + return segment.startsWith("['") && segment.endsWith("']"); + } + + private static String decodeQuotedPropertySegment(String segment) { + return segment.substring(2, segment.length() - 2) + .replace("\\'", "'") + .replace("\\\\", "\\"); + } + + private static boolean isArrayIndexSegment(String segment) { + return segment.startsWith("[") && segment.endsWith("]"); + } + + 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..bffcf2c1b7 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,48 @@ 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); } + /** + * Converts the value of one entry from an {@code @MCOtherAttributes} map. + * Example: for `methods: { 'rpc.echo': { params: ... } }` the key is `rpc.echo`. + * The nested object is then bound as the configured Java type for the map value, + * not left as a raw map. Plain scalar values such as `timeout: 5` stay plain values. + */ + 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..5ee2c797db 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,152 @@ 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 otherAttributesKeyWithQuoteWrongFieldRendersErrorReport() 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/JsonRPCMethodSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodSchemas.java new file mode 100644 index 0000000000..d4f5490545 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodSchemas.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 JsonRPCMethodSchemas { + + 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/JsonRPCMethodsDefinitions.java b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodsDefinitions.java new file mode 100644 index 0000000000..a967867ddc --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCMethodsDefinitions.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 JsonRPCMethodsDefinitions { + + 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..931ca02f4d --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/json/rpc/JsonRPCSchemaValidation.java @@ -0,0 +1,260 @@ +/* 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.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; +import static java.util.Collections.unmodifiableMap; + +/** + * @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 JsonRPCMethodsDefinitions methods = new JsonRPCMethodsDefinitions(); + 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(JsonRPCMethodsDefinitions methods) { + this.methods = methods == null ? new JsonRPCMethodsDefinitions() : methods; + } + + public JsonRPCMethodsDefinitions 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<>(); + + resolveJsonRpcSchemas(resolver, uriFactory, beanBaseLocation, resolvedParamSchemas, registry, resolvedResponseSchemas); + + paramSchemas = unmodifiableMap(resolvedParamSchemas); + responseSchemas = unmodifiableMap(resolvedResponseSchemas); + } + + private void resolveJsonRpcSchemas(Resolver resolver, URIFactory uriFactory, String beanBaseLocation, Map resolvedParamSchemas, SchemaRegistry registry, Map resolvedResponseSchemas) { + for (Map.Entry entry : methods.getMethods().entrySet()) { + String methodName = validateMethodName(entry.getKey()); + JsonRPCMethodSchemas 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); + } + } + + 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 JsonRPCMethodSchemas requireDefinitions(String methodName, JsonRPCMethodSchemas 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/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..e9be96361a --- /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/security/JsonRpcAllowDenyAndBatchValidationTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcAllowDenyAndBatchValidationTutorialTest.java new file mode 100644 index 0000000000..afe625584b --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcAllowDenyAndBatchValidationTutorialTest.java @@ -0,0 +1,126 @@ +/* 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.security; + +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 JsonRpcAllowDenyAndBatchValidationTutorialTest extends AbstractSecurityTutorialTest { + + @Override + protected String getTutorialDir() { + return "security/json-rpc"; + } + + @Override + protected String getTutorialYaml() { + return "10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml"; + } + + @Test + void allowsConfiguredMethod_ping() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"ping"} + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200) + .contentType(JSON) + .body("jsonrpc", equalTo("2.0")) + .body("id", equalTo(1)) + .body("result.ok", equalTo(true)); + // @formatter:on + } + + @Test + void rejectsMethodOutsideAllowlist() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + {"jsonrpc":"2.0","id":1,"method":"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("shutdown")); + // @formatter:on + } + + @Test + void acceptsBatchUpToMaxSize() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + [ + {"jsonrpc":"2.0","id":1,"method":"ping"}, + {"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]} + ] + """) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200) + .contentType(JSON) + .body("size()", equalTo(2)) + .body("[0].jsonrpc", equalTo("2.0")) + .body("[0].id", equalTo(1)) + .body("[0].result.ok", equalTo(true)) + .body("[1].jsonrpc", equalTo("2.0")) + .body("[1].id", equalTo(2)) + .body("[1].result.ok", equalTo(true)); + // @formatter:on + } + + @Test + void rejectsBatchThatExceedsMaxSize() { + // @formatter:off + given() + .contentType(JSON) + .body(""" + [ + {"jsonrpc":"2.0","id":1,"method":"ping"}, + {"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]}, + {"jsonrpc":"2.0","id":3,"method":"ping"} + ] + """) + .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/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcProtectionTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcProtectionTutorialTest.java new file mode 100644 index 0000000000..1299d1e6be --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/security/JsonRpcProtectionTutorialTest.java @@ -0,0 +1,140 @@ +/* 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.security; + +import com.predic8.membrane.tutorials.json.AbstractJsonTutorialTest; +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 getTutorialDir() { + return "security/json-rpc"; + } + + @Override + protected String getTutorialYaml() { + return "20-JSON-RPC-Protection-with-Schema-Validation.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..f13a59b1e9 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. ## [XML](xml) @@ -25,6 +25,11 @@ If your APIs use XML as input or output, this tutorial provides useful configura Learn how to use Membrane in more advanced scenarios. Topics include path rewriting, scripting, conditions and more. +## [Security](security) + +This tutorial covers transport and message protection topics such as TLS termination, authentication, ACLs, JSON/XML/GraphQL protection, and a dedicated JSON-RPC tutorial with allow/deny, batch validation, and schema validation examples. + + ## [AI / MCP](ai/mcp) Expose Membrane as an MCP server for AI clients, inspect recent API traffic, and protect the MCP endpoint with an API key. diff --git a/distribution/tutorials/json/README.md b/distribution/tutorials/json/README.md index d2a149b503..b5d142588c 100644 --- a/distribution/tutorials/json/README.md +++ b/distribution/tutorials/json/README.md @@ -6,5 +6,3 @@ This tutorial covers working with JSON in Membrane API Gateway, including: - JSON message transformations 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 diff --git a/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml b/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml new file mode 100644 index 0000000000..838486fcee --- /dev/null +++ b/distribution/tutorials/security/json-rpc/10-JSON-RPC-Allow-Deny-and-Batch-Validation.yaml @@ -0,0 +1,92 @@ +# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.2.json +# +# Tutorial: JSON-RPC Allow/Deny and Batch Validation +# +# Small JSON-RPC protection example: +# - allows only the methods `ping` and `sum` +# - rejects every other method +# - limits batch requests to at most 2 calls +# +# Notes: +# - Method rules are evaluated top-down. +# - The first matching rule wins. +# +# Try: +# +# 1.) Allowed request +# curl -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -H "Content-Type: application/json" http://localhost:2000 +# +# 2.) Blocked method +# curl -d '{"jsonrpc":"2.0","id":1,"method":"shutdown"}' -H "Content-Type: application/json" http://localhost:2000 +# +# Should return a JSON-RPC error explaining that the method is not allowed. +# +# 3.) Valid batch with 2 calls +# curl -d '[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]}]' -H "Content-Type: application/json" http://localhost:2000 +# +# Should return a JSON-RPC batch response with one success entry per request. +# +# 4.) Batch too large +# curl -d '[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","id":2,"method":"sum","params":[1,2]},{"jsonrpc":"2.0","id":3,"method":"ping"}]' -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 + methods: + - allow: ping + - allow: sum + - deny: .* + target: + url: http://localhost:3000 + +--- +# Demo backend. +# Returns a success response for every request (depending on the request type single or batch). +api: + port: 3000 + flow: + - if: + test: body.toString().trim().startsWith('[') + flow: + - static: + contentType: application/json + pretty: true + src: | + [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "ok": true + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "ok": true + } + } + ] + - return: + status: 200 + else: + - static: + contentType: application/json + pretty: true + src: | + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "ok": true + } + } + - return: + status: 200 diff --git a/distribution/tutorials/security/json-rpc/20-JSON-RPC-Protection-with-Schema-Validation.yaml b/distribution/tutorials/security/json-rpc/20-JSON-RPC-Protection-with-Schema-Validation.yaml new file mode 100644 index 0000000000..7dd1828650 --- /dev/null +++ b/distribution/tutorials/security/json-rpc/20-JSON-RPC-Protection-with-Schema-Validation.yaml @@ -0,0 +1,88 @@ +# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.2.json +# +# Tutorial: JSON-RPC Protection with Schema Validation +# +# 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: '.*' # 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/security/json-rpc/echo-params.schema.json b/distribution/tutorials/security/json-rpc/echo-params.schema.json new file mode 100644 index 0000000000..0b9ad4703d --- /dev/null +++ b/distribution/tutorials/security/json-rpc/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/security/json-rpc/echo-result.schema.json b/distribution/tutorials/security/json-rpc/echo-result.schema.json new file mode 100644 index 0000000000..0b9ad4703d --- /dev/null +++ b/distribution/tutorials/security/json-rpc/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 +} diff --git a/distribution/tutorials/security/json-rpc/membrane.cmd b/distribution/tutorials/security/json-rpc/membrane.cmd new file mode 100644 index 0000000000..8d2d64e9cf --- /dev/null +++ b/distribution/tutorials/security/json-rpc/membrane.cmd @@ -0,0 +1,24 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + +set "dir=%SCRIPT_DIR%" + +:search_up +if exist "%dir%\LICENSE.txt" if exist "%dir%\scripts\run-membrane.cmd" goto found +for %%A in ("%dir%\..") do set "next=%%~fA" +if /I "%next%"=="%dir%" goto notfound +set "dir=%next%" +goto search_up + +:found +set "MEMBRANE_HOME=%dir%" +set "MEMBRANE_CALLER_DIR=%SCRIPT_DIR%" +call "%MEMBRANE_HOME%\scripts\run-membrane.cmd" %* +exit /b %ERRORLEVEL% + +:notfound +>&2 echo Could not locate Membrane root. Ensure directory structure is correct. +exit /b 1 diff --git a/distribution/tutorials/security/json-rpc/membrane.sh b/distribution/tutorials/security/json-rpc/membrane.sh new file mode 100755 index 0000000000..195dae51ec --- /dev/null +++ b/distribution/tutorials/security/json-rpc/membrane.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Default: ./proxies.xml (next to this script); fallback -> $MEMBRANE_HOME/conf/proxies.xml +# JAVA_OPTS: relative -D paths are auto-resolved against $MEMBRANE_HOME (absolute/URI unchanged). +# Examples: +# export JAVA_OPTS='-Dlog4j.configurationFile=examples/logging/access/log4j2_access.xml' +# export JAVA_OPTS='-Dlog4j.configurationFile=/abs/path/log4j2.xml' + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) + +dir="$SCRIPT_DIR" +while [ "$dir" != "/" ]; do + if [ -f "$dir/LICENSE.txt" ] && [ -f "$dir/scripts/run-membrane.sh" ]; then + export MEMBRANE_HOME="$dir" + export MEMBRANE_CALLER_DIR="$SCRIPT_DIR" + exec sh "$dir/scripts/run-membrane.sh" "$@" + fi + dir=$(dirname "$dir") +done + +echo "Could not locate Membrane root. Ensure directory structure is correct." >&2 +exit 1 \ No newline at end of file diff --git a/distribution/tutorials/security/json-rpc/run-docker.cmd b/distribution/tutorials/security/json-rpc/run-docker.cmd new file mode 100644 index 0000000000..871ae08922 --- /dev/null +++ b/distribution/tutorials/security/json-rpc/run-docker.cmd @@ -0,0 +1,15 @@ +@echo off +setlocal enabledelayedexpansion + +set "DIR=%~dp0" +set "IMAGE=predic8/membrane:7.2.2" + +for /f "delims=" %%i in ('docker create -p 2000-2010:2000-2010 %IMAGE% %*') do set "CID=%%i" + +set "CLEANUP_CMD=docker rm -f %CID% >nul 2>nul" + +docker cp "%DIR%." "%CID%:/opt/membrane/" >nul +docker start -a "%CID%" + +%CLEANUP_CMD% +endlocal diff --git a/distribution/tutorials/security/json-rpc/run-docker.sh b/distribution/tutorials/security/json-rpc/run-docker.sh new file mode 100755 index 0000000000..914b5b7236 --- /dev/null +++ b/distribution/tutorials/security/json-rpc/run-docker.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +cid="$(docker create -it -p 2000-2010:2000-2010 predic8/membrane:7.2.2 "$@")" + +cleanup() { + docker rm -f "$cid" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker cp "${DIR}/." "${cid}:/opt/membrane/" +docker start -a "$cid"